@x12i/activix
v9.0.6
Published
Generic two-phase activity lifecycle tracker built on @x12i/xronox-store
Maintainers
Readme
@x12i/activix
Part of the Activitix monorepo. Shared record types live in @x12i/activix-contracts@^1.3.0 (re-exported from this package for convenience).
For runtime optimization of AI execution (first scenario: adaptive max completion budget), use @x12i/optimixer (2.x). Optimixer writes optimixer:prediction rows through your Activix client; Activix does not make optimization decisions.
Track start → complete / fail (and timeouts) for activities stored in MongoDB (via @x12i/xronox-store) or in a local playground folder (no database). Each record has root-level outer (input, output, metadata, optional cost) and optional inner[], optional top-level metadata (orchestrator observability) and config (routing mirror), a runContext object (pass sessionId / jobId for correlation — Activix does not invent sessionId if you omit it), primary key activityId, and lifecycle fields (status, startTime, endTime, duration) that Activix sets (integrators should not send those or parallel createdAt / updatedAt).
Docs map: Record fields & naming · .docs/activity-structure.md · .docs/activity-trace-schema.md (studio export / joins) · .docs/run-context-object.md · v9 consumers: ../../.docs/MIGRATION-CONSUMERS.md · v6: .docs/MIGRATION-v6.md · v5 runContext: .docs/MIGRATION-v5.md
Install
@x12i/xronox-store is a private dependency on npmjs. Make sure your npm auth token has access to the @x12i org (see .npmrc.example), then:
npm install @x12i/activixThe published package ships .docs/ (same as this repo). Dependents can link or copy from node_modules/@x12i/activix/.docs/run-context-object.md when documenting their own Activix integration.
Activation policy (important)
Activix is explicitly enabled by config.
- Enable Activix by constructing
new Activix(...)with either:- a pre-built
store, or collection/collections(and usuallymongoUrior Mongo URI env vars when you expect database storage).
- a pre-built
- Default
storageMode(when omitted): ifmongoUri(or Mongo URI env vars) is set →database(no probe; fails at store init when Mongo is down). If no URI →localplayground. For probe-then-fallback dev ergonomics, passstorageMode: 'automatic'explicitly. - Set
storageMode: 'database'for the same behavior as omitting mode when a URI is present. SetstorageMode: 'local'for playground-only (see Storage modes). await Activix.create(options)is equivalent tonew Activix(options)thenawait ax.init()(convenience helper).- If your app does not construct Activix, treat it as disabled (do not silently auto-enable from ambient env).
- If Activix is enabled, missing required connection details should fail fast at app wiring/startup.
- Collection name is required application wiring. Every package must pass a package-owned, hard-coded
collectionorcollectionsvalue when constructing Activix. Do not pick runtime package collection names from.env. - Database name is not accepted in constructor options. Set DB once in
.env(ACTIVIX_DB_NAME, fallbackMONGO_AI_LOGS_DB, thenMONGO_LOGS_DB, thenMONGO_DB), otherwise Activix usesactivitix. - Diagnostic logs are error-only by default. This prevents feedback loops where routine Activix diagnostics would add noise around activity-log writes. Set
ENABLE_ACTIVIX_LOGXER=trueto enable full console diagnostics. When enabled, level followsACTIVIX_LOGS_LEVEL(canonical; legacyACTIVIX_LOG_LEVELfallback); if both are unset, the default iswarn. SetACTIVIX_LOGS_LEVEL=errorto return to errors-only while diagnostics are enabled. Injectedloggerinstances are also gated: with the enable flag off, onlylogger.erroris called. Structured logging via @x12i/logxer is provided by @x12i/optimixer, not Activix. - Dependencies below Activix use their own log switches (documented here for convenience only; each package remains authoritative). To trace persistence end-to-end, enable each layer you need (see Diagnostic logging down the stack).
This keeps behavior deterministic across environments and avoids hidden coupling to process env.
Run context at runtime (not Activix constructor config)
runContext is per-run execution context, not something you define once in new Activix({ … }) or in static app configuration.
- Constructor options (
mongoUri,collection/collections,store, index definitions, storage mode, and similar) only configure where and how documents are stored. They do not carrysessionId,jobId,taskId, or other correlation fields for a specific request, message, or job. - On each write (
startRecord, and any follow-up that carries run context), pass therunContextobject that reflects this execution: usually taken from the inbound request or job envelope, optionally extended at each layer, and forwarded to downstream code. That is the object persisted under the configuredrunContextfield (default BSON keyrunContext; override withrunContextField). Full guide:.docs/run-context-object.md.
If you omit both runContext.sessionId and top-level sessionId, Activix does not invent a value—it logs a warning and stores the record without sessionId. For end-to-end correlation across services, supply sessionId (and other work-scope fields) from the true upstream of the run.
v5 naming: the correlation object is runContext only—there is no identity field, identityField config, or findRecordsByIdentity in the default public API (no backward-compatible aliases). Stray identity keys on writes are not read as the correlation envelope. v5 API summary is at the top of .docs/run-context-object.md. To announce this upgrade to teams, use .docs/COMMUNICATING-RUNCONTEXT-V5.md.
Diagnostic logging down the stack
Activix sits on xronox-store, which sits on xronox. Diagnostic output is layered: turning on Activix logs does not automatically enable store or engine logs. The table below is reference for Activix users only; do not treat it as instructions to modify downstream repos — pull specifics from each dependency when you need them.
| Layer | Package | Turn on | Notes |
|-------|---------|---------|--------|
| Activix | @x12i/activix | Default: errors only. Full diagnostics: ENABLE_ACTIVIX_LOGXER=true; optional ACTIVIX_LOGS_LEVEL (error, warn, info, debug, etc.) | Avoids logging-system noise about logs activity. isActivixDiagnosticLoggingEnabled() is true only when the enable flag is true and the level is not silenced. |
| Store | @x12i/xronox-store | XRONOX_STORE_LOG=1 (or true / yes / on) | Console lines prefixed [xronox-store]. Optional logger on XronoxStore; see xronox-store README — Logging. |
| Engine | @x12i/xronox | XRONOX_VERBOSE=1 or XRONOX_LOGS=1 (1, true, or yes) | Only XRONOX_* keys; generic DEBUG=1 does not enable xronox diagnostics. |
Example for a noisy trace of one persistence path:
ENABLE_ACTIVIX_LOGXER=true
ACTIVIX_LOGS_LEVEL=debug
XRONOX_STORE_LOG=1
XRONOX_VERBOSE=1Expanded reference (same scope — Activix docs only) and logs-gateway pointers: .docs/logging-stack.md.
Breaking change: no constructor DB override
mongoDb in new Activix(...) is intentionally unsupported.
Why:
- Prevent hidden per-component DB selection in code.
- Avoid accidental split-brain activity history (some services writing to one DB, others to another).
- Keep one operational source of truth in deployment/env config.
Quick start (single collection)
Use this when all activity records for this Activix instance live in one MongoDB collection. Pass the collection name once in the constructor; you do not pass it again on startRecord, completeRecord, and the other methods.
The collection name is part of the package's source-level ownership contract. Choose a stable package-owned name such as my-service-activities and hard-code it in your Activix initialization. Environment variables are for deployment settings like Mongo URI or database name, not for deciding which package-owned activity collection a runtime package writes to.
Production (default): Mongo when URI is configured — no probe, fail fast if Mongo is down.
import {
Activix,
activixActivityIo,
activixOuterTier,
type ActivixRunContext,
} from '@x12i/activix';
const ax = new Activix({
mongoUri: process.env.MONGO_URI!,
collection: 'my-service-activities',
});
await ax.init(); // ax.storageBackend === 'database' when Mongo is reachable
const runContext: ActivixRunContext = {
sessionId: '550e8400-e29b-41d4-a716-446655440000', // normally the id your upstream already assigned
jobId: 'job-123',
};
const { activityId } = await ax.startRecord({
runContext,
...activixActivityIo(
activixOuterTier({ kind: 'task', args: { id: 1 } }, null, { type: 'import' })
),
});
await ax.completeRecord(activityId, {
outer: { output: { result: 'ok' } },
});
await ax.close();Always MongoDB (no probe; same behavior as pre–automatic-default releases):
const ax = new Activix({
storageMode: 'database',
mongoUri: process.env.MONGO_URI!,
collection: 'my-service-activities',
});You can pass a full config object instead of a string if you need custom primary keys, indexes, or field names:
const ax = new Activix({
mongoUri: process.env.MONGO_URI!,
collection: {
name: 'my-service-activities',
indexes: [
{ keys: { 'runContext.jobId': 1 } },
{ keys: { 'runContext.sessionId': 1 } }, // helps findRecordsByRunContext({ sessionId })
],
},
});Defaults (v6)
| Item | Default |
|------|---------|
| Primary key field | activityId (values like <primaryKeyPrefix><uuid>; default act-<uuid>) |
| runContext object | Field runContext (override with runContextField); pass sessionId / jobId when you have them (runContext or top-level sessionId — Activix warns if sessionId is missing) |
| outer / inner | Root fields (no structure wrapper): outer requires input, output (may be null until complete), metadata, optional cost; optional inner is an array of step entries with input, output, metadata, optional cost, startedAt, endedAt, optional durationMs |
| autoCost | Default false. When enabled, fills outer.cost from a valid cost on the row or from @x12i/ai-tools only when needed — see Automatic cost |
| Status / times | status, startTime, endTime, duration — numbers (Unix ms for start/end); set only by Activix |
| Mid-flight status | statusValues.inProgress default 'in_progress' (markInProgress) |
| Abandoned / stale | statusValues.timeout default 'timeout'; TTL staleRecordTTL (ms) vs startTime (reconcileAbandonedActivities / markStaleRecords) |
| Purge old rows | purgeOldRecords() — soft-purges (marks as purgedAt and hides) docs with startTimeField older than purgeRecordMaxAgeMs (default 7 days); optional per-call olderThanMs |
| Optional “last heard” | progressAtField — set on each markInProgress |
startRecord creates the primary key, writes it to both the configured primary-key field and root activityId, persists the row, and returns activityId. Pass that activityId into completeRecord, failRecord, markInProgress, patchRecord, and getRecord. Deprecated recordId on the result object is the same string.
Record shape: where each value belongs
Use this table before choosing field names in your service. Types and trace helpers live in @x12i/activix-contracts (re-exported from @x12i/activix).
| Concept | Where it lives | Example | Notes |
|---------|----------------|---------|-------|
| Collection name | Constructor collection / collections[].name | 'ai-gateway-activities' | Hard-coded in source (not .env). Optimixer activixCollection must match when sharing one Activix client. |
| activityId | Root on every row | 'act-…' | Assigned by Activix on startRecord. Join key for graph ↔ ai-tasks. Not stored in runContext. |
| runContext | Root envelope, per write | { sessionId, jobId, graphId, nodeId } | .docs/run-context-object.md |
| activityKind | IO / lifecycle tag | 'node:start', 'optimixer:prediction' | Often outer.input.kind or outer.metadata.kind. Export: trace.activityKind. |
| activityType | Domain label | 'synthesis', 'task' | outer.metadata.type only — not legacy ActivityTracker routing. Export: trace.activityType. |
| Top-level metadata | Root sibling of outer | { modelUsed, synthesisEnabled } | Orchestrator observability; shallow-merged on updates; not hoisted into outer. |
| outer.metadata | On outer | { phase, provider, model, kind } | IO-tier / gateway mirror. |
| Top-level config | Root routing mirror | { provider, model, maxTokens } | Filled from outer.metadata on persist via materializeRecordRoutingAndBilling. |
activityKind vs activityType
Do not use these interchangeably:
activityKind— which phase or IO event? (graph:start,node:start,optimixer:prediction). Activix resolves warnings/playground labels in order: top-levelkind→metadata.kind→outer.metadata.kind→outer.input.kind.activityType— which domain bucket? (synthesis,task,import). Canonical slot:outer.metadata.type.
import { activixOuterTier, activixActivityIo } from '@x12i/activix';
await ax.startRecord({
runContext: { sessionId: jobId, jobId, graphId: 'g1', nodeId: 'node-a' },
...activixActivityIo(
activixOuterTier(
{ kind: 'node:start', payload: {} }, // outer.input
null,
{ type: 'task', kind: 'node:start' } // outer.metadata
)
),
metadata: { modelUsed: 'openai/gpt-4o', synthesisEnabled: false },
});Top-level metadata, config, and observability
| Field | Typical use |
|-------|-------------|
| metadata.modelUsed | Model id for studio reports (diagnostics also read config.model / outer.metadata.model) |
| metadata.synthesisEnabled | Explicit boolean for synthesis on/off |
| metadata.* | Orchestrator keys (narrixMode, inputStrategyKey, …) — query { "metadata.<key>": value } |
| config | Routing/billing mirror (provider, model, maxTokens) — safe to patch on failRecord for gateway error paths |
Legacy rows with only outer.metadata.type: 'synthesis' are treated as synthesis-enabled in export summaries when synthesisEnabled is absent.
Pure helpers: resolveRecordMetadata, mergeRecordMetadata, resolveRecordConfig, mergeRecordConfig, materializeRecordRoutingAndBilling, buildActivityTraceBlock, normalizeActivityTraceExport.
Graph runs and ai-tasks joins
Correlation stays in runContext; inherit upstream ids, extend with your scope:
// Graph run (no nodeId) — graphRun in getJobActivities is the row with graphId and no nodeId
runContext: { sessionId: jobId, jobId, graphId: 'g1' }
// Master skill scope (product-defined; often another row's activityId)
runContext: { sessionId: jobId, jobId, graphId: 'g1', masterSkillActivityId: 'act-master-9' }
// ai-tasks child pointing at graph node
metadata: { runTask: { metadata: { activityId: 'act-node-abc' } } }Studio join: graph node trace.join.activityId === ai-tasks trace.join.parentActivityId. Details: .docs/activity-trace-schema.md.
Lifecycle: done, in-progress, failed, abandoned
| Intent | API |
|--------|-----|
| Success (sets completed, endTime, duration) | completeRecord(activityId, updates?) |
| Failure (sets failed, error, endTime, duration) | failRecord(activityId, error, updates?) |
| Still running — “last we heard” (sets in_progress, optional touch field) | markInProgress(activityId, updates?) |
| Arbitrary fields, any status (including after completed / failed) | patchRecord(activityId, fields) |
Optional per collection: progressAtField (e.g. lastHeardAt) — on each markInProgress, Activix sets it to Date.now(). Override the mid-flight label with statusValues.inProgress.
Persistence warnings
Pass onPersistenceWarning when you want a metrics/logging hook for failed activity writes:
const ax = new Activix({
collection: 'my-service-activities',
mongoUri: process.env.MONGO_URI!,
onPersistenceWarning: (warning) => {
metrics.increment(warning.event, {
collection: warning.collection,
phase: warning.phase,
});
},
});Activix calls this hook before it rethrows or swallows a persistence error. Normal lifecycle writes (startRecord, completeRecord, failRecord, markInProgress) still reject after the warning. patchRecord keeps its historical non-fatal behavior by default, but strictPersistence: true makes it rethrow after warning so CI/audit-sensitive runs can fail fast.
Warning events are activix.record.insert_failed and activix.record.update_failed. The payload includes collection, operation, phase, primary key, and an error summary. phase is resolved from top-level kind, then outer.metadata.kind, then outer.input.kind, else activix:record. When xronox-store reports clone diagnostics, Activix forwards the offending path/type summary under warning.clone.
Automatic cost (autoCost)
Opt in with autoCost: true (or an options object) on new Activix({ … }). On completeRecord, failRecord, markInProgress, and patchRecord, Activix can populate outer.cost when it is missing or invalid:
- Reuse a valid cost already on the row — e.g.
outer.cost.usd,inner[].cost,outer.output.usage.total_cost/usage.cost, or other nested cost objects (bounded scan). Integrator-providedouter.costwith a validusdis never replaced unless you setoverwriteOuterCost: true. - Only if no valid cost was found, call
@x12i/ai-tools(CostCalculator.calculateFromRecord) when token usage and a model can be extracted. Activix checksneedsActivixCostCalculation(record)first so catalog pricing is skipped when unnecessary.
Model strings (ai-tools v5.1.0 / ai-profiles 4.4.x): pricing accepts profile/choice keys (e.g. cheap/default → google/gemma-3-4b-it) or concrete model ids (e.g. gpt-5.5, openai/gpt-4o). Bare profiles (cheap), shortcuts (standard), and legacy shorthands (gpt4o) are not resolved — store the canonical form on activity rows.
Default catalog mode for Activix is bundledOnly: true (offline-friendly). Pass bundledOnly: false to allow live open-assets catalog refresh.
const ax = new Activix({
collection: 'my-service-activities',
mongoUri: process.env.MONGO_URI!,
autoCost: true,
});
await ax.completeRecord(activityId, {
outer: {
output: {
usage: { prompt_tokens: 1200, completion_tokens: 400, total_cost: 0.008 },
},
},
});
// outer.cost.usd === 0.008 (from the response; ai-tools is not called)Standalone helpers (same rules): applyActivixAutoCost, findValidActivixCostInRecord, needsActivixCostCalculation, resolveActivixCostForRecord, isValidActivixCost.
Abandoned activities (started but never finished)
Call reconcileAbandonedActivities() on a timer (or from your job runner). It finds documents still in started whose startTime is older than a TTL and sets status to statusValues.timeout (default 'timeout'). Default TTL is constructor staleRecordTTL (ms); override per run with { ttlMs }. Same implementation as markStaleRecords() — use whichever name fits your docs.
Purging old records (soft purge)
Call purgeOldRecords(options?) on a schedule to soft-purge documents whose startTime is older than a threshold (any status, including stuck or finished).
Activix marks matching rows with purgedAt = Date.now() (or your purgeAtField). When Activix creates XronoxStore, it registers the same field as xronox-store visibility, so tombstoned rows are hidden from getByKey / readMany / updateMany (i.e. getRecord, findRecords, findRecordsByRunContext, stale reconciliation, and purge matching).
- Default age: constructor
purgeRecordMaxAgeMs, which defaults to 7 days (604800000ms). - Override per run:
purgeOldRecords({ olderThanMs: 24 * 60 * 60 * 1000 })(example: older than 1 day). - You can configure the marker field name per collection with
purgeAtField(default:purgedAt).
const ax = new Activix({
mongoUri: process.env.MONGO_URI!,
purgeRecordMaxAgeMs: 7 * 24 * 60 * 60 * 1000, // default; omit to use 1 week
});
await ax.init();
const purged = await ax.purgeOldRecords(); // or { collection: 'other-stream', olderThanMs: ... }Migrating from v1 (recordId / rec-)
If existing collections and indexes use recordId, set in config:
primaryKey: 'recordId', primaryKeyPrefix: 'rec-'
Cache-first reads (primary key)
When Activix creates the store, each collection’s cache is resolved with xronox-store resolveCacheConfig (defaults maxSize 10000, ttlMs 0) and your optional cache overrides. Writes go to the cache first, then Mongo. getRecord(id), completeRecord, markInProgress, patchRecord, and failRecord use the store’s per-key cache when the key is hot.
findRecords / findRecordsByRunContext use readMany. By default they do not merge the in-memory cache; pass { mergeCache: true } (xronox-store 1.2+) to union matching cached rows with DB results. After a write, getRecord(activityId) is still the simplest way to read the latest row for one id in-process.
Query by sessionId / runContext / status
Use findRecordsByRunContext(criteria, { collection?, limit?, sort? }) to load activities from the database by:
sessionId— matches<runContextField>.sessionId(default field pathrunContext.sessionId)runContext— extra exact matches on nested run context, e.g.{ tenant: 'acme' }→runContext.tenantstatus— exact match on your configured status field (e.g.'started','completed')
Criteria are combined with AND. You must pass at least one of: a non-empty sessionId, at least one runContext property, or a non-empty status. If top-level sessionId and runContext.sessionId both appear, they must be the same string or Activix throws.
// All activities in a session
const rows = await ax.findRecordsByRunContext({ sessionId: '550e8400-e29b-41d4-a716-446655440000' });
// Same, but only still-open ones
const open = await ax.findRecordsByRunContext({
sessionId: '550e8400-e29b-41d4-a716-446655440000',
status: 'started',
});
// By custom run-context fields (uses configured runContextField name, default `runContext`)
const forTenant = await ax.findRecordsByRunContext({
runContext: { tenant: 'acme', plan: 'pro' },
status: 'in_progress',
});For ad hoc Mongo shapes, use findRecords(rawFilter, …) instead. Consider an index on runContext.sessionId (and other queried run-context keys) in indexes on the collection config.
Exported type: FindByRunContextCriteria.
Runtime observability query client
Activix is the official owner of activity queryability for runtime observability. Packages that expose debug-only runtimeObjects should expose their package-owned Activix instance by reference as runtimeObjects.activixClient; they should not build local Mongo adapters, scrape playground files, or query xronox-store internals.
Use getJobActivities(input) when a parent package, playground, or debug UI needs all Activix activity information for a runtime job:
const result = await ax.getJobActivities({
jobId: 'job-123',
graphId: 'graph-main',
nodeId: 'node-a',
limit: 500,
});
console.log(result.jobId, result.graphRun, result.activities);Shape:
type ActivixQueryableClient = {
getJobActivities(input: {
jobId: string;
graphId?: string;
nodeId?: string;
limit?: number;
}): Promise<{
jobId: string;
graphRun?: unknown;
activities: unknown[];
}>;
};jobId is required and is matched inside the configured runContextField (default runContext.jobId). Optional graphId and nodeId filter the same run-context object. Activix queries every collection configured on the instance, returns full activity rows, sorts them into timeline order, and applies limit after merging collections.
The configured store is the source of truth: MongoDB/xronox-store in database mode, or the playground/local store in local mode or automatic fallback. Activix passes mergeCache: true so hot in-process rows can be merged with the store result where supported, but the cache is only a freshness layer, not an in-memory-only backend.
Full guide: .docs/runtime-observability-querying.md.
Graph-style writes (indexes recommended on runContext.jobId, runContext.graphId, runContext.nodeId):
await ax.startRecord({
runContext: { sessionId: jobId, jobId, graphId: 'g1', nodeId: 'node-a' },
outer: {
input: { kind: 'node:start' },
output: null,
metadata: { type: 'task', kind: 'node:start' },
},
});getJobActivities({ jobId, graphId }) returns graphRun (first matching graph-level row) plus activities sorted in timeline order.
Run context contract for gateway integrations
This is the same runtime runContext object described in Run context at runtime (not Activix constructor config); gateway docs spell out the envelope for AI-shaped stacks.
For teams integrating through @athenices/ai-gateway, use the shared run-context envelope in:
.docs/run-context-object.md— whatrunContextis, layers,instance, examples, mistakes, v5 API, graphs/hooks appendix.docs/session-id-usage.md— happy-pathsessionIdfor a full run; nested layers should not replace an id they received; entry/direct-call behavior is product-defined.docs/activity-structure.md— rootouter/inner[]I/O (including optionalouter.cost/inner[].cost)
Recommended shape for cross-service correlation (see the doc for hierarchy vs optional executor):
runContext: {
sessionId: string,
// Work scope: add as you go deeper (examples — names are product-defined)
jobId?: string,
taskId?: string,
stepId?: string,
skillId?: string,
// Optional: which executor ran the work (agent / worker / replica)
instance?: {
instanceId: string,
type: string,
},
}Activix does not mint sessionId for you — pass it from the job/request upstream when you need correlation. Gateway-facing systems should preserve the full runContext object end-to-end.
Multiple collections
Use this when one service writes to several activity collections (for example separate streams per domain). List each collection in collections and set defaultCollection to the primary stream name (explicit in source — there is no implicit “first item” default). For non-default streams, pass collection on the method call.
const ax = new Activix({
mongoUri: process.env.MONGO_URI!,
collections: [
{ name: 'workflow-runs' },
{ name: 'import-jobs' },
],
defaultCollection: 'workflow-runs',
});
await ax.startRecord({ step: 1 }); // uses workflow-runs
await ax.startRecord({ file: 'a.csv' }, { collection: 'import-jobs' });Collection names always come from your application code (collection or collections in the constructor), so each app owns its own table names.
Jobs collection (job metadata)
If you want a dedicated collection for job-level metadata (separate from activity rows), you must wire it in when constructing Activix by adding it to the constructor’s collections array (recommended name: jobs) with primary key jobId, and set jobsCollection to that same name (or pass { collection: 'jobs' } on every job helper call). Activix does not assume a default jobs collection name.
Then use startJob() / endJob():
import { Activix } from '@x12i/activix';
const ax = new Activix({
mongoUri: process.env.MONGO_URI!,
collections: [
{ name: 'my-service-activities' }, // your activity stream(s)
{ name: 'jobs', primaryKey: 'jobId' }, // job metadata
],
defaultCollection: 'my-service-activities',
jobsCollection: 'jobs',
});
await ax.init();
await ax.startJob({
jobId: 'job-123',
description: 'Import customers from CRM',
startedAt: Date.now(),
identityObject: {
source: {
sourceType: 'mongo',
identifier: { property: '_id', value: '66f0...' },
objectType: 'customer',
},
},
// optional correlation envelope (stored under the collection’s configured `runContextField`)
runContext: { sessionId: '...', jobId: 'job-123' },
});
await ax.endJob({ jobId: 'job-123', endedAt: Date.now() });Fetch job metadata or list/search recent jobs:
const job = await ax.getJob('job-123'); // ActivixJobRecord | null
const jobs = await ax.listJobs({ limit: 50, searchText: 'customers' });Fetch job metadata + all activity rows for that job (across every configured activity collection):
const bundle = await ax.getJobBundle({ jobId: 'job-123' });
// bundle.job uses constructor jobsCollection (or pass { jobsCollection: 'jobs' } here)
// bundle.activities -> activity rows matched by runContext.jobId across all collectionsTypes exported by this package:
ActivixJobRecord—{ jobId, description, startedAt, endedAt?, status: 'in-progress' | 'ended', identityObject, runContext? }ActivixJobStatus
Collection legend / registry (activix-collections)
When Activix creates the store (you do not pass a custom store), it registers activix-collections automatically if it is not already in your collections list (primary key collectionName). After await init(), it inserts one legend document per configured collection (including the registry collection itself) when a row is missing. Rows use diagnostics.owner (recommended: npm package id) or collectionRegistry.owner as the owner stamp and a short auto-generated about line (override the template via collectionRegistry.aboutTemplate; use {name} as the collection-name placeholder).
Set collectionRegistry: false to disable both behaviors (used by the activix CLI). Custom store integrators must define the registry collection on the store and on Activix themselves if they want the same behavior.
If you need rich legend metadata (friendlyName, tags, custom about) for specific collections, list those names in collectionRegistry.skipAutoInsertForCollections and call initializeCollection(...) after init() for each — initializeCollection is insert-only and does nothing when a row already exists.
const ax = new Activix({
mongoUri: process.env.MONGO_URI!,
diagnostics: { owner: '@my-scope/my-service', component: 'worker' },
collections: [
{ name: 'my-service-activities' },
{ name: 'jobs', primaryKey: 'jobId' },
],
defaultCollection: 'my-service-activities',
jobsCollection: 'jobs',
collectionRegistry: {
skipAutoInsertForCollections: ['jobs'],
},
});
await ax.init();
await ax.initializeCollection({
collectionName: 'jobs',
friendlyName: 'Jobs',
tags: ['jobs'],
about: 'Job metadata records (one doc per jobId)',
owner: { package: '@my-scope/my-service', component: 'worker' },
});Use tags to control whether callers query ALL activity collections or just a subset:
- ALL (activities): tag every activity stream with
activities, then uselistActivityCollections()(this returns only legends taggedactivities). - Tag subsets: add tags like
['activities','ai','gateway']and filter by tags (still always constrained toactivities).
Example (Activities UI selects only activity streams, optionally narrowed):
const activityCollections = await ax.listActivityCollections();
// Narrow to AI gateway activity streams only:
const gatewayCollections = await ax.listActivityCollections({ tags: ['gateway'] });
// Or search by legend text:
const searched = await ax.listActivityCollections({ searchText: 'gateway' });Fetch legend data and configured collections:
const configured = ax.listConfiguredCollections(); // [{ name, primaryKey }, ...]
const legend = await ax.getCollectionLegend('jobs');
const legends = await ax.listCollectionLegends({ limit: 100, searchText: 'job', tags: ['jobs'] });One-time backfill for an existing database (creates missing legend rows only):
npm run legend:backfillTypes exported by this package:
ActivixCollectionLegendRecordActivixCollectionLegendOwnerActivixCollectionTrackingState—'track' | 'off'
Collection tracking state — who saves what?
Each row in activix-collections describes one MongoDB collection (name, owner, about, …) and may include state, which controls whether Activix persists activity rows for that collection.
Integrator package Activix MongoDB
───────────────── ─────── ───────
startRecord / completeRecord / → reads legend.state (cached) → insert/update
failRecord / patchRecord / … track → write (or skip when off)
off → skip write, same APIRule for integrator packages
Always call Activix on every operation (startRecord, completeRecord, failRecord, patchRecord, markInProgress, startJob, endJob, …). Do not read state or branch in application code — let Activix decide whether the row reaches Mongo. When state is off, the same methods still run and return the same result shapes; only storage is skipped (in-memory for the current process).
Legend state values
| Value | Meaning |
|-------|---------|
| track (default) | Activix persists activity rows for that collection |
| off | Activix skips database writes for that collection |
New legend rows get state: 'track' on insert when Activix creates them. Legacy rows without state in Mongo are treated as track (same as missing/null/unknown values).
How Activix reads state (cache + TTL)
Before each write, Activix resolves the collection's legend state. Results are cached per collection so Activix does not query activix-collections on every startRecord. The cache is refreshed when:
| Trigger | When it applies |
|---------|-----------------|
| TTL expiry | Default 600 s since the last read for that collection |
| setCollectionTrackingState() | Immediately after a programmatic toggle (updates DB + cache) |
| refreshCollectionTrackingStates() | When you call it after an external change (Mongo UI, another service) |
TTL resolution (first match wins):
collectionTrackingStateRefreshIntervalSec— Activix constructor option (seconds)ACTIVIX_COLLECTION_TRACKING_STATE_TTL_SEC— environment (seconds)600
Set TTL to 0 to re-read legend state on every write (no cache). Helper: resolveActivixCollectionTrackingStateTtlSec().
Changing state
Programmatic (recommended for ops / studio):
await ax.setCollectionTrackingState('ai-activities', 'off');
// Integrators keep calling startRecord / completeRecord — nothing is written to Mongo
await ax.setCollectionTrackingState('ai-activities', 'track');At registration (insert-only; ignored if the legend row already exists):
await ax.initializeCollection({
collectionName: 'my-service-activities',
about: 'Workflow activity stream',
owner: { package: '@my-scope/my-service' },
state: 'track',
});External edit (Mongo UI, migration script, another Activix instance) — tell this instance to re-read:
// One collection
const states = await ax.refreshCollectionTrackingStates({ collection: 'ai-activities' });
// states['ai-activities'] === 'track' | 'off'
// Every configured activity collection (excludes the legend registry itself)
const all = await ax.refreshCollectionTrackingStates();Without refreshCollectionTrackingStates(), external changes take effect only after the TTL (default 10 minutes) or process restart.
Component-level manager (Graphs Studio / runtime objects)
For operator UIs that toggle tracking per runtime package before a graph run, use createActivixTrackingManager(activix) and expose it as runtimeObjects.activixTrackingManager (see .docs/runtime-observability-querying.md).
import { createActivixTrackingManager, composeActivixTrackingManagers } from '@x12i/activix';
const activixTrackingManager = createActivixTrackingManager(ax);
const targets = await activixTrackingManager.listTrackingTargets();
// componentId from legend owner.component ?? owner.package
await activixTrackingManager.setTrackingState({
componentId: '@exellix/graph-engine',
state: 'off',
});Parent packages compose child managers with composeActivixTrackingManagers([...]) so each package keeps ownership of its collections. Types: ActivixTrackingManager, ActivixTrackingTarget, PackageRuntimeObjects (@x12i/activix-contracts).
Unique indexes and nulls
MongoDB unique indexes treat null like any other value. If a field (e.g. activityId) is indexed unique, do not persist null for it. Activix now generates the key before insert and writes it to both the configured primary-key field and root activityId so writes do not reach Mongo with activityId: null. runContext.sessionId is set only when you pass it (in runContext or as top-level sessionId); otherwise it is omitted. Prefer sparse unique indexes for optional fields, or omit the field instead of setting null.
Database name
When Activix creates the store for you, the MongoDB database name is resolved from environment only:
process.env.ACTIVIX_DB_NAMEprocess.env.MONGO_AI_LOGS_DBprocess.env.MONGO_LOGS_DBprocess.env.MONGO_DB- the string
activitix
You can use the same resolution in your own code with resolveActivixLogsDatabaseName() (exported from this package).
Collection tracking state TTL uses ACTIVIX_COLLECTION_TRACKING_STATE_TTL_SEC (default 600). See Collection tracking state.
MongoDB URI for connection tests and default (automatic) init()
For testActivixMongoConnection() and for the automatic path inside await init() when you omit mongoUri, the URI is resolved by resolveActivixMongoUriFromEnv():
process.env.MONGO_LOGS_URI- then
process.env.MONGO_URI
There is no silent fallback to a default URI: if both are unset and you do not pass mongoUri, the health check returns { ok: false, reason: '…' } and automatic / default init() falls back to local playground storage.
MongoDB connection check (single attempt)
Use testActivixMongoConnection(options?) to verify connectivity once (no retry loop): open a client, ping the target database, close. Returns:
{ ok: true }if the ping succeeds{ ok: false, reason: string }if the URI is missing, the driver fails to load, or connect/ping throws
Options (all optional):
| Option | Role |
|--------|------|
| mongoUri | Connection string; else env via resolveActivixMongoUriFromEnv() |
| mongoDb | Database name for ping; else resolveActivixLogsDatabaseName() |
| serverSelectionTimeoutMS | Server selection / connect timeout for that single attempt (default 5000) |
This package depends on the mongodb driver for this probe (also used indirectly by the xronox stack).
import {
testActivixMongoConnection,
resolveActivixMongoUriFromEnv,
resolveActivixLogsDatabaseName,
} from '@x12i/activix';
const check = await testActivixMongoConnection({
mongoUri: process.env.MONGO_URI,
mongoDb: resolveActivixLogsDatabaseName(),
});
if (!check.ok) {
console.error('MongoDB not usable:', check.reason);
}Activity persistence verification (no mongodb import in your app)
For smoke tests, CI, and operators, use Activix-owned helpers so gateway and application repos do not depend on the mongodb driver directly. Resolution matches Activix for URI via resolveActivixMongoUriFromEnv() and database via resolveActivixLogsDatabaseName(). resolveActivixPersistenceTarget and the snapshot helpers require a collection string in options (package-owned name in code). resolveActivixActivitiesCollectionName() is for CLI/smoke scripts only: it reads ACTIVIX_COLLECTION or MONGO_LOGS_COLLECTION and throws if neither is set (no default name).
To trace live writes through Activix → xronox-store → xronox, enable diagnostics per layer (separate env vars); see Diagnostic logging down the stack and .docs/logging-stack.md.
API (read-only; opens a short-lived client per call):
| Export | Role |
|--------|------|
| countActivixActivitiesInMongo(options) | Document count ( options.collection required ) |
| getActivixLatestActivitySummariesInMongo(limit, options) | Latest limit rows by startTime descending |
| getActivixActivityPersistenceSnapshotInMongo(limit, options) | { count, latest } in one connection |
| summarizeActivixActivityForDiagnostics(doc, options?) | Pure helper: same summary shape from any in-memory / exported document |
| resolveActivixPersistenceTarget(options) | Resolved URI, DB name, collection, and field names (collection required) |
By default, counts and queries exclude tombstoned rows (purgedAt non-null), consistent with typical Activix visibility. Pass { purgeAtField: false } to include them.
Each summary includes activityId, runContext.jobId when present, status, provider, model (prefers metadata.modelUsed → metadata.model → config.model → outer.metadata.model), maxTokens, token usage flags, costUsd, hasResponse, startTime, endTime. Override field names with the same options Activix uses (runContextField, primaryKeyField) if your collection config differs from defaults. Legacy rows with nested structure.outer are still summarized when outer is absent at the root.
For studio-oriented exports, use buildActivityTraceBlock / directory export (below) — same resolution rules, stable trace.observability shape.
import { getActivixActivityPersistenceSnapshotInMongo } from '@x12i/activix';
const { count, latest } = await getActivixActivityPersistenceSnapshotInMongo(5, {
collection: 'my-package-activities',
});
console.log({ count, latest });CLI (after npm install; binary activix):
npx activix verify --collection my-activities
npx activix verify --limit 10 --json --collection my-activities
# Or set ACTIVIX_COLLECTION or MONGO_LOGS_COLLECTION instead of --collection
npx activix verify --mongo-uri "$MONGO_URI" --include-tombstoned --collection my-activitiesDirectory export (studio / support bundles)
Write JSON snapshots under a folder (playground-style layout):
const { targetDir, totalRecords, byCollection } = await ax.exportAllRecordsToDirectory('./export-out');
// Optional: only recent rows
await ax.exportRecordsWithinKeepLastHoursToDirectory('./export-recent', 24);Layout:
export-manifest.json— counts,exportedAt, optionalkeepLastHourscollections/<collectionName>/records/<activityId>.json— one file per row
By default each file includes the persisted document plus a normalized trace block for reporting (does not change Mongo):
{
"activityId": "act-node-abc",
"runContext": { "jobId": "job-1", "graphId": "g1", "nodeId": "node-a" },
"outer": { "input": {}, "output": {}, "metadata": { "type": "task" } },
"trace": {
"activixCollection": "graph-engine-activities",
"activityKind": "node:start",
"activityType": "task",
"correlation": { "sessionId": "job-1", "jobId": "job-1", "graphId": "g1", "nodeId": "node-a" },
"join": { "activityId": "act-node-abc" },
"observability": { "modelUsed": "openai/gpt-4o", "synthesisEnabled": false }
}
}traceShape: false— raw rows only (notraceblock)mergeCache,includeHidden— same asfindRecords
Spec: .docs/activity-trace-schema.md. CLI: npx activix export … (see npx activix --help).
Storage modes
How to set storageMode (all optional except you must pick a valid combination with store):
| Value | When to use |
|-------|-------------|
| (omit) | Context-aware default (v9+): URI present (option or env) → database; no URI → local playground. No silent fallback when Mongo is configured but down. |
| 'automatic' | Explicit dev/CI opt-in. On await init(), run testActivixMongoConnection() once; Mongo reachable → XronoxStore; else → playground ('local'). storageBackend is 'pending' until init() finishes. |
| 'database' | Always MongoDB — no probe; construct XronoxStore immediately. Same as omitting mode when a URI is available. |
| 'local' | Always playground folder — no MongoDB (see playground options). |
Custom store: storageMode is ignored for picking the implementation; Activix uses your store. storageBackend is 'local' for ActivixPlaygroundStore / ActivixMemoryStore, otherwise 'database'. Do not pass storageMode: 'local' together with a custom store.
| storageBackend | Meaning |
|------------------|---------|
| 'pending' | Automatic mode, init() not completed yet. |
| 'database' | MongoDB (XronoxStore or compatible custom store). |
| 'local' | Playground folder or in-memory custom store. |
Activix.create() (construct + init)
import { Activix } from '@x12i/activix';
// Same as: const ax = new Activix({ ... }); await ax.init(); return ax;
const ax = await Activix.create({
mongoUri: process.env.MONGO_URI,
collection: 'my-service-activities',
logger: console,
playground: { outputDir: 'playground', runId: 'my-run-id' },
});
// ax.storageBackend === 'database' or 'local' (never 'pending' here)Explicit storageMode examples
// Dev / CI: probe once on init, fall back to playground when Mongo is down
new Activix({ storageMode: 'automatic', collection: 'activities', mongoUri: process.env.MONGO_URI });
// Production: omit storageMode when mongoUri (or env URI) is set — same as database
new Activix({ collection: 'activities', mongoUri: process.env.MONGO_URI! });
// Mongo only (explicit)
new Activix({ storageMode: 'database', collection: 'activities', mongoUri: process.env.MONGO_URI! });
// Playground only
new Activix({ storageMode: 'local', collection: 'activities', playground: { outputDir: 'playground' } });Repeated Mongo init logs across packages
Initialization is per Activix instance. In storageMode: 'automatic', each instance performs:
- one Mongo connectivity probe (
testActivixMongoConnection) - one real store initialization (
store.init)
If your architecture creates multiple Activix instances (for example different packages or workers), repeated probe/init logs are expected and do not mean one instance is double-initializing. Treat this as an optimization concern only when instance creation frequency becomes expensive or side effects appear.
You can pass optional diagnostics metadata to make logs self-identifying:
new Activix({
collection: 'activities',
mongoUri: process.env.MONGO_URI,
diagnostics: {
owner: '@woroces/worox-graph',
component: 'ai-tasks-client',
instanceLabel: 'graph-executor',
workerId: process.pid.toString(),
},
});Troubleshooting repeated init logs
- Enable Activix and store diagnostics (
ENABLE_ACTIVIX_LOGXER=true,ACTIVIX_LOGS_LEVEL=debug,XRONOX_STORE_LOG=1). - Group logs by
activixInstanceId. - Verify each instance shows one
activix.init.mongo_probe.start+ oneactivix.init.store_init.start. - If repeated logs share the same
activixInstanceIdwith risinginitCallCount, inspect caller lifecycle. - If repeated logs have different
activixInstanceIdvalues, behavior is expected for multi-instance architecture.
Local playground layout
With storageMode: 'local' (or after automatic fallback), Activix uses ActivixPlaygroundStore under playground.outputDir (default "playground"). Optional playground.runId appears in the Markdown header.
Typical artifacts:
report.md— human-readable activity timeline (also written onclose())activities.jsonl— one JSON line per persisted mutationcollections/<name>/records/<pk>.json— snapshot per primary keymeta.json— metadata including dispatch sequence for numbered sidecarsNN-<activityKey>-request.json/-response.json— when rows carry playground work shapes (work:request/fullRequest,work:response/fullOutput, etc.)
Helpers on Activix when using built-in playground storage:
getPlaygroundStore()—ActivixPlaygroundStore | nullgetPlaygroundMarkdown()— current timeline as Markdown, ornullwritePlaygroundReport(targetPath?)— writereport.md(default: under the playground root); returns absolute path ornull
For in-memory-only testing without the filesystem, you can still construct a custom ActivixMemoryStore and pass store (see Pre-built store).
Pre-built store
If you already have a XronoxStore, pass store (and usually skipStoreInit: true if it is already initialized). Use the same primaryKey / primaryKeyPrefix in both the store’s collections and Activix’s collection / collections config so inserts and reads agree.
For soft-purge behavior to match Activix, each store collection must set visibility with field equal to Activix’s purgeAtField (default purgedAt) and the same mode (default hiddenIfNonNull). Otherwise Activix will still write the purge timestamp, but getRecord / findRecords will not hide those rows unless the store applies visibility.
Documentation
| Topic | Doc |
|-------|-----|
| I/O tiers (outer / inner, metadata vs config) | .docs/activity-structure.md |
| Studio export, trace block, graph ↔ ai-tasks joins | .docs/activity-trace-schema.md |
| runContext layers, masterSkillActivityId, graphs | .docs/run-context-object.md |
| sessionId ownership | .docs/session-id-usage.md |
| Debug / getJobActivities | .docs/runtime-observability-querying.md |
| Contributor / CI | .docs/README.md |
| Full specification | .docs/activix.spec.md |
| Monorepo consumers (Activix 8 + Optimixer 2) | ../../.docs/MIGRATION-CONSUMERS.md |
| Optimixer (runtime optimization) | ../optimixer/README.md |
Scripts
npm run build— ESM + CJS outputsnpm test— main lifecycle integration tests (MongoDB +.env)npm run test:single— single-collectionconstructor modenpm run test:options— database name resolution and constructor validation (no MongoDB)npm run test:local— playgroundstorageMode: 'local'(temp dir; no MongoDB)npm run test:patch/test:stale/test:gaps/test:spy/test:purge— focused integration suitesnpm run test:all— everything above
Tests compile to .tests-out/ as ESM and import dist/index.js. See .docs/build-and-test.md.
CI
After this tree is the Git repo root, GitHub Actions runs .github/workflows/ci.yml (build; enable integration tests when NODE_AUTH_TOKEN, Mongo secrets, and a DB are configured).
License
Athenix License
