refactor(core): Drop the Instance AI run snapshot store (no-changelog) (#37204)

This commit is contained in:
Raúl Gómez Morales
2026-09-02 11:29:04 +00:00
committed by GitHub
parent d997348352
commit 4b3acc85dd
56 changed files with 517 additions and 3948 deletions
-15
View File
@@ -83,7 +83,6 @@ Auto-generated from the PostgreSQL migrations in @n8n/db. Do not edit by hand.
| [public.instance_ai_observations](public.instance_ai_observations.md) | 10 | | BASE TABLE |
| [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) | 12 | | BASE TABLE |
| [public.instance_ai_resources](public.instance_ai_resources.md) | 5 | | BASE TABLE |
| [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) | 11 | | BASE TABLE |
| [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | 5 | | BASE TABLE |
| [public.instance_ai_threads](public.instance_ai_threads.md) | 7 | | BASE TABLE |
| [public.instance_ai_workflow_snapshots](public.instance_ai_workflow_snapshots.md) | 7 | | BASE TABLE |
@@ -279,7 +278,6 @@ erDiagram
"public.instance_ai_pending_confirmations" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.instance_ai_pending_confirmations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_pending_confirmations" }o--o| "public.instance_ai_checkpoints" : "FOREIGN KEY (#quot;checkpointKey#quot;) REFERENCES instance_ai_checkpoints(key) ON DELETE CASCADE"
"public.instance_ai_run_snapshots" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_thread_grants" }o--|| "public.user" : "FOREIGN KEY (#quot;userId#quot;) REFERENCES #quot;user#quot;(id) ON DELETE CASCADE"
"public.instance_ai_thread_grants" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
@@ -1139,19 +1137,6 @@ erDiagram
timestamp_3__with_time_zone updatedAt
text workingMemory
}
"public.instance_ai_run_snapshots" {
timestamp_3__with_time_zone createdAt
varchar_36_ langsmithRunId
varchar_36_ langsmithTraceId
varchar_36_ messageGroupId
varchar_36_ runId
json runIds
varchar_64_ spanId
uuid threadId FK
varchar_64_ traceId
text tree
timestamp_3__with_time_zone updatedAt
}
"public.instance_ai_thread_grants" {
timestamp_3__with_time_zone createdAt
varchar_512_ grantKey
@@ -1,72 +0,0 @@
# public.instance_ai_run_snapshots
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| langsmithRunId | varchar(36) | | true | | | LangSmith run ID (UUID v4, e.g. "f47ac10b-58cc-4372-a567-0e02b2c3d479"). |
| langsmithTraceId | varchar(36) | | true | | | LangSmith trace ID (UUID v4, e.g. "f47ac10b-58cc-4372-a567-0e02b2c3d479"). |
| messageGroupId | varchar(36) | | true | | | |
| runId | varchar(36) | | false | | | |
| runIds | json | | true | | | |
| spanId | varchar(64) | | true | | | OpenTelemetry span ID for the root Instance AI run. |
| threadId | uuid | | false | | [public.instance_ai_threads](public.instance_ai_threads.md) | |
| traceId | varchar(64) | | true | | | OpenTelemetry trace ID for the root Instance AI run. |
| tree | text | | false | | | |
| updatedAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| FK_2f63fa21d09d7918f347ddbdf70 | FOREIGN KEY | FOREIGN KEY ("threadId") REFERENCES instance_ai_threads(id) ON DELETE CASCADE |
| PK_0a5fc9690a84950ebf1416fb146 | PRIMARY KEY | PRIMARY KEY ("threadId", "runId") |
| instance_ai_run_snapshots_createdAt_not_null | n | NOT NULL "createdAt" |
| instance_ai_run_snapshots_runId_not_null | n | NOT NULL "runId" |
| instance_ai_run_snapshots_threadId_not_null | n | NOT NULL "threadId" |
| instance_ai_run_snapshots_tree_not_null | n | NOT NULL tree |
| instance_ai_run_snapshots_updatedAt_not_null | n | NOT NULL "updatedAt" |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_d3a2bc880e7a8626802e5474ad | CREATE INDEX "IDX_d3a2bc880e7a8626802e5474ad" ON public.instance_ai_run_snapshots USING btree ("threadId", "createdAt") |
| IDX_d926c16c2ad9728cb9a81790c0 | CREATE INDEX "IDX_d926c16c2ad9728cb9a81790c0" ON public.instance_ai_run_snapshots USING btree ("threadId", "messageGroupId") |
| PK_0a5fc9690a84950ebf1416fb146 | CREATE UNIQUE INDEX "PK_0a5fc9690a84950ebf1416fb146" ON public.instance_ai_run_snapshots USING btree ("threadId", "runId") |
## Relations
```mermaid
erDiagram
"public.instance_ai_run_snapshots" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_run_snapshots" {
timestamp_3__with_time_zone createdAt
varchar_36_ langsmithRunId
varchar_36_ langsmithTraceId
varchar_36_ messageGroupId
varchar_36_ runId
json runIds
varchar_64_ spanId
uuid threadId FK
varchar_64_ traceId
text tree
timestamp_3__with_time_zone updatedAt
}
"public.instance_ai_threads" {
timestamp_3__with_time_zone createdAt
uuid id
json metadata
varchar_36_ projectId FK
varchar_255_ resourceId
text title
timestamp_3__with_time_zone updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+1 -15
View File
@@ -5,7 +5,7 @@
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | timestamp(3) with time zone | CURRENT_TIMESTAMP(3) | false | | | |
| id | uuid | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) [public.instance_ai_events](public.instance_ai_events.md) [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) [public.instance_ai_messages](public.instance_ai_messages.md) [public.instance_ai_observation_cursors](public.instance_ai_observation_cursors.md) [public.instance_ai_observation_locks](public.instance_ai_observation_locks.md) [public.instance_ai_observational_memory](public.instance_ai_observational_memory.md) [public.instance_ai_observations](public.instance_ai_observations.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_run_snapshots](public.instance_ai_run_snapshots.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | | |
| id | uuid | | false | [public.ai_builder_temporary_workflow](public.ai_builder_temporary_workflow.md) [public.instance_ai_checkpoints](public.instance_ai_checkpoints.md) [public.instance_ai_events](public.instance_ai_events.md) [public.instance_ai_iteration_logs](public.instance_ai_iteration_logs.md) [public.instance_ai_messages](public.instance_ai_messages.md) [public.instance_ai_observation_cursors](public.instance_ai_observation_cursors.md) [public.instance_ai_observation_locks](public.instance_ai_observation_locks.md) [public.instance_ai_observational_memory](public.instance_ai_observational_memory.md) [public.instance_ai_observations](public.instance_ai_observations.md) [public.instance_ai_pending_confirmations](public.instance_ai_pending_confirmations.md) [public.instance_ai_thread_grants](public.instance_ai_thread_grants.md) | | |
| metadata | json | | true | | | |
| projectId | varchar(36) | | false | | [public.project](public.project.md) | Project this thread is scoped to |
| resourceId | varchar(255) | | false | | | |
@@ -48,7 +48,6 @@ erDiagram
"public.instance_ai_observational_memory" }o--o| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE SET NULL"
"public.instance_ai_observations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;observationScopeId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_pending_confirmations" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_run_snapshots" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_thread_grants" }o--|| "public.instance_ai_threads" : "FOREIGN KEY (#quot;threadId#quot;) REFERENCES instance_ai_threads(id) ON DELETE CASCADE"
"public.instance_ai_threads" }o--|| "public.project" : "FOREIGN KEY (#quot;projectId#quot;) REFERENCES project(id) ON DELETE CASCADE"
@@ -180,19 +179,6 @@ erDiagram
timestamp_3__with_time_zone updatedAt
uuid userId FK
}
"public.instance_ai_run_snapshots" {
timestamp_3__with_time_zone createdAt
varchar_36_ langsmithRunId
varchar_36_ langsmithTraceId
varchar_36_ messageGroupId
varchar_36_ runId
json runIds
varchar_64_ spanId
uuid threadId FK
varchar_64_ traceId
text tree
timestamp_3__with_time_zone updatedAt
}
"public.instance_ai_thread_grants" {
timestamp_3__with_time_zone createdAt
varchar_512_ grantKey
-15
View File
@@ -83,7 +83,6 @@ Auto-generated from the SQLite migrations in @n8n/db. Do not edit by hand.
| [instance_ai_observations](instance_ai_observations.md) | 10 | | table |
| [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) | 12 | | table |
| [instance_ai_resources](instance_ai_resources.md) | 5 | | table |
| [instance_ai_run_snapshots](instance_ai_run_snapshots.md) | 11 | | table |
| [instance_ai_thread_grants](instance_ai_thread_grants.md) | 5 | | table |
| [instance_ai_threads](instance_ai_threads.md) | 7 | | table |
| [instance_ai_workflow_snapshots](instance_ai_workflow_snapshots.md) | 7 | | table |
@@ -262,7 +261,6 @@ erDiagram
"instance_ai_pending_confirmations" }o--o| "instance_ai_checkpoints" : "FOREIGN KEY (checkpointKey) REFERENCES instance_ai_checkpoints (key) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_pending_confirmations" }o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_pending_confirmations" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_run_snapshots" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_thread_grants" |o--|| "user" : "FOREIGN KEY (userId) REFERENCES user (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_thread_grants" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -1126,19 +1124,6 @@ erDiagram
datetime_3_ updatedAt
TEXT workingMemory
}
"instance_ai_run_snapshots" {
datetime_3_ createdAt
varchar_36_ langsmithRunId
varchar_36_ langsmithTraceId
varchar_36_ messageGroupId
varchar_36_ runId PK
TEXT runIds
varchar_64_ spanId
varchar threadId PK
varchar_64_ traceId
TEXT tree
datetime_3_ updatedAt
}
"instance_ai_thread_grants" {
datetime_3_ createdAt
varchar_512_ grantKey PK
@@ -1,80 +0,0 @@
# instance_ai_run_snapshots
## Description
<details>
<summary><strong>Table Definition</strong></summary>
```sql
CREATE TABLE "instance_ai_run_snapshots" ("threadId" varchar NOT NULL, "runId" varchar(36) NOT NULL, "messageGroupId" varchar(36), "runIds" text, "tree" text NOT NULL, "createdAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "updatedAt" datetime(3) NOT NULL DEFAULT (STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), "langsmithRunId" varchar(36), "langsmithTraceId" varchar(36), "traceId" varchar(64), "spanId" varchar(64), CONSTRAINT "FK_2f63fa21d09d7918f347ddbdf70" FOREIGN KEY ("threadId") REFERENCES "instance_ai_threads" ("id") ON DELETE CASCADE ON UPDATE NO ACTION, PRIMARY KEY ("threadId", "runId"))
```
</details>
## Columns
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| langsmithRunId | varchar(36) | | true | | | |
| langsmithTraceId | varchar(36) | | true | | | |
| messageGroupId | varchar(36) | | true | | | |
| runId | varchar(36) | | false | | | |
| runIds | TEXT | | true | | | |
| spanId | varchar(64) | | true | | | |
| threadId | varchar | | false | | [instance_ai_threads](instance_ai_threads.md) | |
| traceId | varchar(64) | | true | | | |
| tree | TEXT | | false | | | |
| updatedAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
## Constraints
| Name | Type | Definition |
| ---- | ---- | ---------- |
| - (Foreign key ID: 0) | FOREIGN KEY | FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE |
| runId | PRIMARY KEY | PRIMARY KEY (runId) |
| sqlite_autoindex_instance_ai_run_snapshots_1 | PRIMARY KEY | PRIMARY KEY (threadId, runId) |
| threadId | PRIMARY KEY | PRIMARY KEY (threadId) |
## Indexes
| Name | Definition |
| ---- | ---------- |
| IDX_d3a2bc880e7a8626802e5474ad | CREATE INDEX "IDX_d3a2bc880e7a8626802e5474ad" ON "instance_ai_run_snapshots" ("threadId", "createdAt") |
| IDX_d926c16c2ad9728cb9a81790c0 | CREATE INDEX "IDX_d926c16c2ad9728cb9a81790c0" ON "instance_ai_run_snapshots" ("threadId", "messageGroupId") |
| sqlite_autoindex_instance_ai_run_snapshots_1 | PRIMARY KEY (threadId, runId) |
## Relations
```mermaid
erDiagram
"instance_ai_run_snapshots" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_run_snapshots" {
datetime_3_ createdAt
varchar_36_ langsmithRunId
varchar_36_ langsmithTraceId
varchar_36_ messageGroupId
varchar_36_ runId PK
TEXT runIds
varchar_64_ spanId
varchar threadId PK
varchar_64_ traceId
TEXT tree
datetime_3_ updatedAt
}
"instance_ai_threads" {
datetime_3_ createdAt
varchar id PK
TEXT metadata
varchar_36_ projectId FK
varchar_255_ resourceId
TEXT title
datetime_3_ updatedAt
}
```
---
> Generated by [tbls](https://github.com/k1LoW/tbls)
+1 -15
View File
@@ -16,7 +16,7 @@ CREATE TABLE "instance_ai_threads" ("id" varchar PRIMARY KEY NOT NULL, "resource
| Name | Type | Default | Nullable | Children | Parents | Comment |
| ---- | ---- | ------- | -------- | -------- | ------- | ------- |
| createdAt | datetime(3) | STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') | false | | | |
| id | varchar | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [instance_ai_checkpoints](instance_ai_checkpoints.md) [instance_ai_events](instance_ai_events.md) [instance_ai_iteration_logs](instance_ai_iteration_logs.md) [instance_ai_messages](instance_ai_messages.md) [instance_ai_observation_cursors](instance_ai_observation_cursors.md) [instance_ai_observation_locks](instance_ai_observation_locks.md) [instance_ai_observational_memory](instance_ai_observational_memory.md) [instance_ai_observations](instance_ai_observations.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_run_snapshots](instance_ai_run_snapshots.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) | | |
| id | varchar | | false | [ai_builder_temporary_workflow](ai_builder_temporary_workflow.md) [instance_ai_checkpoints](instance_ai_checkpoints.md) [instance_ai_events](instance_ai_events.md) [instance_ai_iteration_logs](instance_ai_iteration_logs.md) [instance_ai_messages](instance_ai_messages.md) [instance_ai_observation_cursors](instance_ai_observation_cursors.md) [instance_ai_observation_locks](instance_ai_observation_locks.md) [instance_ai_observational_memory](instance_ai_observational_memory.md) [instance_ai_observations](instance_ai_observations.md) [instance_ai_pending_confirmations](instance_ai_pending_confirmations.md) [instance_ai_thread_grants](instance_ai_thread_grants.md) | | |
| metadata | TEXT | | true | | | |
| projectId | varchar(36) | | false | | [project](project.md) | |
| resourceId | varchar(255) | | false | | | |
@@ -54,7 +54,6 @@ erDiagram
"instance_ai_observational_memory" }o--o| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE SET NULL MATCH NONE"
"instance_ai_observations" }o--|| "instance_ai_threads" : "FOREIGN KEY (observationScopeId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_pending_confirmations" }o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_run_snapshots" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_thread_grants" |o--|| "instance_ai_threads" : "FOREIGN KEY (threadId) REFERENCES instance_ai_threads (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
"instance_ai_threads" }o--|| "project" : "FOREIGN KEY (projectId) REFERENCES project (id) ON UPDATE NO ACTION ON DELETE CASCADE MATCH NONE"
@@ -186,19 +185,6 @@ erDiagram
datetime_3_ updatedAt
varchar userId FK
}
"instance_ai_run_snapshots" {
datetime_3_ createdAt
varchar_36_ langsmithRunId
varchar_36_ langsmithTraceId
varchar_36_ messageGroupId
varchar_36_ runId PK
TEXT runIds
varchar_64_ spanId
varchar threadId PK
varchar_64_ traceId
TEXT tree
datetime_3_ updatedAt
}
"instance_ai_thread_grants" {
datetime_3_ createdAt
varchar_512_ grantKey PK
@@ -1,7 +1,7 @@
/**
* Shared event reducer for Instance AI agent runs.
*
* Used by both the frontend (live SSE updates) and the backend (snapshot building).
* Used by both the frontend (live SSE updates) and the backend (history folds, run-sync bootstrap).
* All state is plain objects/arrays — no Map/Set — so it's Pinia-safe and easy
* to inspect in tests.
*
@@ -457,7 +457,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
agent.result = event.payload.result;
agent.error = event.payload.error;
// A completed/errored agent can't have tool calls still in-flight.
// Clear isLoading so persisted snapshots don't show stale confirmations.
// Clear isLoading so folded history trees don't show stale confirmations.
for (const tc of agent.toolCalls) {
if (tc.isLoading) {
tc.isLoading = false;
@@ -547,7 +547,7 @@ export function reduceEvent(state: AgentRunState, event: InstanceAiEvent): Agent
}
}
// A terminated run can't have tool calls still in-flight.
// Clear isLoading so persisted snapshots don't show stale confirmations.
// Clear isLoading so folded history trees don't show stale confirmations.
if (state.status === 'cancelled' || state.status === 'error') {
for (const tc of Object.values(state.toolCallsById)) {
if (tc.isLoading) {
@@ -292,6 +292,14 @@ export const runStartPayloadSchema = z.object({
.describe(
'Stable ID for the assistant message group that owns this run. Used to reconnect live activity back to the correct assistant bubble.',
),
langsmithRunId: z
.string()
.optional()
.describe('LangSmith root-run ID, so user feedback can annotate the trace after a restart.'),
langsmithTraceId: z
.string()
.optional()
.describe('LangSmith trace ID paired with langsmithRunId for feedback annotation.'),
});
export const runFinishPayloadSchema = z.object({
@@ -1041,8 +1049,8 @@ const eventBase = {
userId: z.string().optional(),
/** Anthropic API response ID (msg_01...) — groups events from the same LLM response. */
responseId: z.string().optional(),
/** Epoch ms stamped once at publish — replays (SSE reconnect, snapshot
* rebuilds) use it to reconstruct real timing instead of "now". */
/** Epoch ms stamped once at publish — replays (SSE reconnect, history
* folds) use it to reconstruct real timing instead of "now". */
ts: z.number().optional(),
};
@@ -38,9 +38,9 @@ import type { IrreversibleMigration, MigrationContext } from '../migration-types
* reproduced, and re-emitting request facts would resurrect dead approval
* prompts), per-call timing (synthesized facts share the snapshot timestamp,
* so durations render as instant — the tree stores no segment timing to do
* better), and langsmith feedback anchors (they live in snapshot COLUMNS,
* which outlive Gate B until the table drops, so feedback on pre-log threads
* keeps resolving unchanged; the Gate B anchor relocation carries them over).
* better), and langsmith feedback anchors (they live in snapshot COLUMNS and
* drop with the table in Gate B; feedback on those turns is still stored, it
* just no longer annotates the LangSmith trace).
*/
// ---------------------------------------------------------------------------
@@ -0,0 +1,44 @@
import type { MigrationContext, ReversibleMigration } from '../migration-types';
const table = 'instance_ai_run_snapshots';
/**
* The agent-tree snapshot store is retired: history and the SSE bootstrap
* derive their trees by folding `instance_ai_events`, so the persisted tree is
* no longer read or written. The LangSmith feedback anchor that used to live
* here rides on the durable log's `run-start` fact from this release on; runs
* recorded before that resolve no anchor, so feedback on those turns is still
* stored but no longer annotates the LangSmith trace — accepted, to keep this
* a plain drop.
*
* The `down` recreates the table at its final schema (base columns plus the
* trace/LangSmith ids added by later migrations) so a rollback restores a
* structurally-identical table — the historical rows are not recoverable.
*/
export class DropInstanceAiRunSnapshotsTable1788336311704 implements ReversibleMigration {
async up({ schemaBuilder }: MigrationContext) {
await schemaBuilder.dropTable(table);
}
async down({ schemaBuilder: { createTable, column } }: MigrationContext) {
await createTable(table)
.withColumns(
column('threadId').uuid.primary.notNull,
column('runId').varchar(36).primary.notNull,
column('messageGroupId').varchar(36),
column('runIds').json,
column('tree').text.notNull,
column('traceId').varchar(64),
column('spanId').varchar(64),
column('langsmithRunId').varchar(36),
column('langsmithTraceId').varchar(36),
)
.withIndexOn(['threadId', 'messageGroupId'])
.withIndexOn(['threadId', 'createdAt'])
.withForeignKey('threadId', {
tableName: 'instance_ai_threads',
columnName: 'id',
onDelete: 'CASCADE',
}).withTimestamps;
}
}
@@ -194,7 +194,7 @@ The agent package — framework-agnostic business logic.
- **Workflow builder** (`workflow-builder/`) — TypeScript SDK source files, parsing, validation, and prompt sections
- **Workspace** (`workspace/`) — sandbox provisioning (n8n sandbox service / Daytona), filesystem abstraction, snapshot management
- **Memory** (`memory/`) — title generation, memory configuration
- **Storage** (`storage/`) — iteration logs, task storage, planned task storage, workflow loop storage, agent tree snapshots
- **Storage** (`storage/`) — iteration logs, task storage, planned task storage, workflow loop storage
- **MCP client** (`mcp/`) — manages connections to external MCP servers, schema sanitization for Anthropic compatibility
- **Domain access** (`domain-access/`) — domain gating and access tracking for external URL approval
- **Stream mapping** (`stream/`) — agent chunk → canonical event translation, HITL consumption
@@ -222,9 +222,9 @@ The n8n integration layer.
replay belong to the durable event log, not the bus
- **Filesystem** — `LocalGateway` (remote daemon via SSE protocol).
See `docs/filesystem-access.md`
- **Persistence** — 13 TypeORM entity/repository pairs for threads, messages,
resources, observations, observation cursors and locks, checkpoints, run
snapshots, event-log entries, pending confirmations, iteration logs, thread
- **Persistence** — 12 TypeORM entity/repository pairs for threads, messages,
resources, observations, observation cursors and locks, checkpoints,
event-log entries, pending confirmations, iteration logs, thread
grants, and MCP registry connections
### `packages/@n8n/api-types` (Shared Types)
@@ -211,7 +211,7 @@ The same storage backend is used for:
- Message history
- Observational memory (observation log, cursors, and task locks)
- Plan storage (thread-scoped in thread metadata)
- Run snapshots and checkpoints (separate tables)
- Checkpoints (separate table)
## Event Bus
+2 -2
View File
@@ -26,8 +26,8 @@ SQLite instance n8n already uses, selected automatically from n8n's own database
configuration.
That backend holds message history, observational memory (observation log,
cursors and task locks), plan state in thread metadata, and run snapshots and
checkpoints in their own tables.
cursors and task locks), plan state in thread metadata, and checkpoints in
their own table.
### Tier 2: Observational Memory
@@ -589,16 +589,16 @@ replaying all SSE events.
1. **Persisted messages** — `@n8n/agents` persists tool invocations, reasoning, and
text in its message format. The backend parses these into rich
`InstanceAiMessage[]` objects with tool calls and flat agent trees.
`InstanceAiMessage[]` objects.
2. **Agent trees** — with the durable log enabled, history folds event-log rows
through `buildAgentTreeFromEvents()` when it reads a page. Stored snapshots
remain as the non-durable path and as a fallback for older history. The
backend updates snapshots when runs and background tasks settle.
2. **Agent trees** — history folds event-log rows through
`buildAgentTreeFromEvents()` when it reads a page. The log is the only tree
source: a message whose run left no log rows renders from its own
text/reasoning content without a tree.
3. **SSE cursor** — the messages response includes `nextEventId`. The frontend
sets its SSE cursor to `nextEventId - 1` so the SSE connection only receives
events that arrived after the historical snapshot. This prevents duplicate
events that arrived after the historical messages. This prevents duplicate
messages on refresh.
### Frontend Flow
@@ -419,7 +419,7 @@ describe('executeResumableStream', () => {
});
const publishedEvents = eventBus.publish.mock.calls.map(([, event]) => event as PublishedEvent);
// The output redactor may merge contiguous deltas, so match by prefix.
// Match the segment's first delta by prefix; deltas publish per chunk.
const first = publishedEvents.find((event) => event.payload?.text?.startsWith('First'));
const toolCall = publishedEvents.find((event) => event.type === 'tool-call');
const second = publishedEvents.find((event) => event.payload?.text === 'Second segment');
@@ -461,7 +461,7 @@ describe('executeResumableStream', () => {
});
const publishedEvents = eventBus.publish.mock.calls.map(([, event]) => event as PublishedEvent);
// The output redactor may merge contiguous deltas, so match by prefix.
// Match each segment's delta by prefix.
const first = publishedEvents.find((event) => event.payload?.text?.startsWith('First'));
const second = publishedEvents.find((event) => event.payload?.text?.startsWith('Second'));
@@ -1,5 +1,5 @@
import { isFinishReason } from '@n8n/agents';
import type { FinishReason, RedactionOptions, StreamResult } from '@n8n/agents';
import type { FinishReason, StreamResult } from '@n8n/agents';
import type { InstanceAiEvent } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { randomUUID } from 'node:crypto';
@@ -11,7 +11,6 @@ import type {
OrchestratorRunStopSignal,
} from './orchestrator-run-control';
import { isQuotaExhaustedError, mapAgentChunkToEvent } from '../stream/map-chunk';
import { OutputRedactor } from '../stream/output-redaction';
import { UsageAccumulator, type RunTokenUsage } from '../stream/usage-accumulator';
import { WorkSummaryAccumulator, type WorkSummary } from '../stream/work-summary-accumulator';
import { parseSuspension, resumeAgentStream } from '../utils/stream-helpers';
@@ -41,12 +40,6 @@ export interface ResumableStreamContext {
onActivity?: () => void;
/** Stop consuming after the current chunk has been mapped and published. */
stopSignal?: () => OrchestratorRunStopSignal | undefined;
/**
* Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
* default policy, which on the durable-log path would persist redacted text
* — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
*/
outputRedaction?: RedactionOptions | false;
}
export interface ManualSuspensionControl {
@@ -254,10 +247,10 @@ function recordSuspension(
}
/**
* Publish redacted events, holding back the primary confirmation-request event in
* Publish events, holding back the primary confirmation-request event in
* manual mode and de-duplicating it. Returns the updated confirmation-tracking state.
*/
function publishRedactedEvents(
function publishEvents(
events: InstanceAiEvent[],
args: {
suspension: SuspensionInfo | undefined;
@@ -318,7 +311,7 @@ interface StreamPassResult {
}
/**
* Consume one stream until it ends (or is cancelled), publishing redacted events,
* Consume one stream until it ends (or is cancelled), publishing events,
* accumulating usage/work, and capturing the first suspension. Returns the pass
* outcome plus the updated response-id / step counters for the next pass.
*/
@@ -328,18 +321,11 @@ async function consumeStreamPass(args: {
options: ExecuteResumableStreamOptions;
workSummaryAccumulator: WorkSummaryAccumulator;
usageAccumulator: UsageAccumulator;
outputRedactor: OutputRedactor;
currentResponseId: string | undefined;
nativeStepIndex: number;
}): Promise<StreamPassResult> {
const {
activeStream,
activeAgentRunId,
options,
workSummaryAccumulator,
usageAccumulator,
outputRedactor,
} = args;
const { activeStream, activeAgentRunId, options, workSummaryAccumulator, usageAccumulator } =
args;
let currentResponseId = args.currentResponseId;
let nativeStepIndex = args.nativeStepIndex;
/**
@@ -448,12 +434,9 @@ async function consumeStreamPass(args: {
const isFinishStep = isRecord(chunk) && chunk.type === 'finish-step';
if ((mappedEvent && !isDeltaChunk) || isFinishStep) syntheticSegmentId = undefined;
// Scan/redact secrets & PII before events reach the user. Buffered
// delta text is released here at structural boundaries, so this may
// expand into several events (or none, while text is held back).
const events = mappedEvent ? outputRedactor.processEvent(mappedEvent) : [];
const events = mappedEvent ? [mappedEvent] : [];
const published = publishRedactedEvents(events, {
const published = publishEvents(events, {
suspension,
confirmationEvent,
confirmationEventPublished,
@@ -510,13 +493,6 @@ export async function executeResumableStream(
let text = options.stream.text;
const workSummaryAccumulator = new WorkSummaryAccumulator();
const usageAccumulator = new UsageAccumulator();
const outputRedactor = new OutputRedactor({
logger: options.context.logger,
threadId: options.context.threadId,
runId: options.context.runId,
agentId: options.context.agentId,
options: options.context.outputRedaction,
});
let currentResponseId: string | undefined;
let nativeStepIndex = 0;
@@ -528,7 +504,6 @@ export async function executeResumableStream(
options,
workSummaryAccumulator,
usageAccumulator,
outputRedactor,
currentResponseId,
nativeStepIndex,
});
@@ -542,11 +517,6 @@ export async function executeResumableStream(
const { suspension, hasError, error, pendingConfirmation, confirmationEvent } = pass;
const { drainedCorrectionsForResume } = pass;
for (const flushed of outputRedactor.flush()) {
workSummaryAccumulator.observe(flushed);
options.context.eventBus.publish(options.context.threadId, flushed);
}
if (options.context.signal.aborted) {
return buildCancelledResult(activeAgentRunId, text, workSummaryAccumulator, usageAccumulator);
}
@@ -1,4 +1,3 @@
import type { RedactionOptions } from '@n8n/agents';
import type { InstanceAiEvent } from '@n8n/api-types';
import type { InstanceAiEventBus } from '../event-bus';
@@ -31,12 +30,6 @@ export interface StreamRunOptions {
logger: Logger;
onActivity?: () => void;
stopSignal?: () => OrchestratorRunStopSignal | undefined;
/**
* Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
* default policy, which on the durable-log path would persist redacted text
* — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
*/
outputRedaction?: RedactionOptions | false;
}
export interface StreamRunResult {
@@ -92,7 +85,6 @@ async function consumeStream(
logger: options.logger,
onActivity: options.onActivity,
stopSignal: options.stopSignal,
outputRedaction: options.outputRedaction,
},
control: { mode: 'manual' },
initialAgentRunId: options.agentRunId,
@@ -5,10 +5,6 @@ export interface AgentTreeSnapshot {
runId: string;
messageGroupId?: string;
runIds?: string[];
traceId?: string;
spanId?: string;
langsmithRunId?: string;
langsmithTraceId?: string;
createdAt?: Date;
updatedAt?: Date;
}
@@ -1,356 +0,0 @@
import type { RedactionOptions } from '@n8n/agents';
import type { InstanceAiEvent } from '@n8n/api-types';
import type { Logger } from '../../logger';
import { OutputRedactor } from '../output-redaction';
function createLogger() {
return { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as unknown as Logger;
}
function createRedactor(logger = createLogger(), options?: RedactionOptions | false) {
return new OutputRedactor({
logger,
threadId: 'thread-1',
runId: 'run-1',
agentId: 'agent-1',
...(options !== undefined ? { options } : {}),
});
}
function textDelta(text: string): InstanceAiEvent {
return { type: 'text-delta', runId: 'run-1', agentId: 'agent-1', payload: { text } };
}
function reasoningDelta(text: string, responseId?: string): InstanceAiEvent {
return {
type: 'reasoning-delta',
runId: 'run-1',
agentId: 'agent-1',
...(responseId ? { responseId } : {}),
payload: { text },
};
}
function collectText(events: InstanceAiEvent[]): string {
return events.map((e) => ('payload' in e && 'text' in e.payload ? e.payload.text : '')).join('');
}
function collectTextOfType(
events: InstanceAiEvent[],
type: 'text-delta' | 'reasoning-delta',
): string {
return collectText(events.filter((e) => e.type === type));
}
describe('OutputRedactor', () => {
it('redacts a secret split across two text deltas', () => {
const redactor = createRedactor();
const emitted = [
...redactor.processEvent(textDelta('your key is sk-ant-')),
...redactor.processEvent(textDelta('api03-aaaaaaaaaaaaaaaa, keep it safe')),
...redactor.flush(),
];
const out = collectText(emitted);
expect(out).toBe('your key is [REDACTED], keep it safe');
expect(out).not.toContain('sk-ant-');
});
it('redacts a PII email when the email category is enabled', () => {
const redactor = createRedactor(createLogger(), { detect: ['email'] });
const emitted = [
...redactor.processEvent(textDelta('email me at jane@example.com ok')),
...redactor.flush(),
];
expect(collectText(emitted)).toBe('email me at [REDACTED] ok');
});
it('redacts credit cards by default but leaves email untouched', () => {
const redactor = createRedactor();
const emitted = [
...redactor.processEvent(textDelta('card 4111 1111 1111 1111 mail jane@example.com')),
...redactor.flush(),
];
const out = collectText(emitted);
expect(out).toBe('card [REDACTED] mail jane@example.com');
});
it('passes non-sensitive prose through unchanged', () => {
const redactor = createRedactor();
const text = 'The workflow completed and produced three items successfully.';
const emitted = [...redactor.processEvent(textDelta(text)), ...redactor.flush()];
expect(collectText(emitted)).toBe(text);
});
describe('channel switches', () => {
const reasoning =
'I should check the FAQ sheet first and then decide whether a ticket is needed.';
const text = 'Let me start by loading the builder guidance and reviewing the knowledge base.';
it('releases the reasoning tail before text that follows it', () => {
const redactor = createRedactor();
const emitted = [
...redactor.processEvent(reasoningDelta(reasoning)),
...redactor.processEvent(textDelta(text)),
...redactor.flush(),
];
// All reasoning must be published before the first text delta —
// otherwise the held-back reasoning tail renders as a stray
// mid-sentence reasoning block in the UI.
const types = emitted.map((e) => e.type);
expect(types.lastIndexOf('reasoning-delta')).toBeLessThan(types.indexOf('text-delta'));
// No content is lost or reordered within a channel.
expect(collectTextOfType(emitted, 'reasoning-delta')).toBe(reasoning);
expect(collectTextOfType(emitted, 'text-delta')).toBe(text);
});
it('releases the text tail before reasoning that follows it', () => {
const redactor = createRedactor();
const emitted = [
...redactor.processEvent(textDelta(text)),
...redactor.processEvent(reasoningDelta(reasoning)),
...redactor.flush(),
];
const types = emitted.map((e) => e.type);
expect(types.lastIndexOf('text-delta')).toBeLessThan(types.indexOf('reasoning-delta'));
expect(collectTextOfType(emitted, 'text-delta')).toBe(text);
expect(collectTextOfType(emitted, 'reasoning-delta')).toBe(reasoning);
});
it('drains the tail under its original responseId', () => {
const redactor = createRedactor();
const emitted = [
...redactor.processEvent(reasoningDelta(reasoning, 'run-1:step:1')),
...redactor.processEvent(textDelta(text)),
...redactor.flush(),
];
const reasoningEvents = emitted.filter((e) => e.type === 'reasoning-delta');
expect(reasoningEvents.length).toBeGreaterThan(1);
for (const event of reasoningEvents) {
expect(event).toMatchObject({ responseId: 'run-1:step:1' });
}
});
});
it('preserves responseId on flushed delta text', () => {
const redactor = createRedactor();
const emitted = [
...redactor.processEvent({
type: 'text-delta',
runId: 'run-1',
agentId: 'agent-1',
responseId: 'run-1:step:1',
payload: { text: 'hello there' },
}),
...redactor.flush(),
];
expect(emitted).toHaveLength(1);
expect(emitted[0]).toMatchObject({ type: 'text-delta', responseId: 'run-1:step:1' });
});
it('redacts secrets nested inside tool-call args', () => {
const redactor = createRedactor();
const event: InstanceAiEvent = {
type: 'tool-call',
runId: 'run-1',
agentId: 'agent-1',
payload: {
toolCallId: 'tc-1',
toolName: 'http_request',
args: { headers: { authorization: 'Bearer abcdef1234567890' } },
},
};
const [out] = redactor.processEvent(event);
expect(JSON.stringify(out)).toContain('[REDACTED]');
expect(JSON.stringify(out)).not.toContain('abcdef1234567890');
// Identifier fields are preserved.
expect(out).toMatchObject({ payload: { toolCallId: 'tc-1', toolName: 'http_request' } });
});
it('redacts secrets nested inside a tool-result', () => {
const redactor = createRedactor();
const event: InstanceAiEvent = {
type: 'tool-result',
runId: 'run-1',
agentId: 'agent-1',
payload: {
toolCallId: 'tc-1',
result: { headers: { authorization: 'Bearer abcdef1234567890' } },
},
};
const [out] = redactor.processEvent(event);
expect(JSON.stringify(out)).toContain('[REDACTED]');
expect(JSON.stringify(out)).not.toContain('abcdef1234567890');
});
it('leaves upstream browser redaction markers intact in a tool-result', () => {
const redactor = createRedactor();
const event: InstanceAiEvent = {
type: 'tool-result',
runId: 'run-1',
agentId: 'agent-1',
payload: {
toolCallId: 'tc-1',
result: {
snapshot: 'Client Secret [REDACTED:secret:1] Signing Secret [REDACTED:secret:2]',
},
},
};
const [out] = redactor.processEvent(event);
const serialized = JSON.stringify(out);
expect(serialized).not.toContain('[REDACTED:[REDACTED');
expect(serialized).toContain('[REDACTED:secret:1]');
expect(serialized).toContain('[REDACTED:secret:2]');
});
it('redacts confirmation card display text', () => {
const redactor = createRedactor(createLogger(), { detect: ['email'] });
const event: InstanceAiEvent = {
type: 'confirmation-request',
runId: 'run-1',
agentId: 'agent-1',
payload: {
requestId: 'req-1',
toolCallId: 'tc-1',
toolName: 'ask',
args: {},
severity: 'warning',
message: 'What to do with jane@example.com?',
targetApproval: {
toolName: 'send_jane@example.com',
displayName: 'Email jane@example.com',
args: { recipient: 'jane@example.com' },
},
introMessage: 'I noticed jane@example.com in your request.',
questions: [
{
id: 'q1',
question: 'Where should jane@example.com be used?',
type: 'single',
options: ['Reply-to jane@example.com', 'Skip'],
},
],
tasks: { tasks: [{ id: 't1', description: 'Email jane@example.com', status: 'todo' }] },
planItems: [
{
id: 'p1',
title: 'Notify jane@example.com',
kind: 'task',
spec: 'Send to jane@example.com',
deps: [],
},
],
},
};
const [out] = redactor.processEvent(event);
const serialized = JSON.stringify(out);
expect(serialized).not.toContain('jane@example.com');
expect(serialized).toContain('[REDACTED]');
// Control/identifier fields are preserved so suspend/resume keeps working.
expect(out).toMatchObject({
payload: {
requestId: 'req-1',
toolCallId: 'tc-1',
targetApproval: {
toolName: '[REDACTED]',
displayName: 'Email [REDACTED]',
args: { recipient: '[REDACTED]' },
},
},
});
expect(event.payload.targetApproval?.args).toEqual({ recipient: 'jane@example.com' });
});
it('withholds target approval args nested beyond the redaction depth limit', () => {
const redactor = createRedactor();
const secret = 'Bearer abcdef1234567890';
let nested: unknown = { secret };
for (let depth = 0; depth < 9; depth++) nested = { nested };
const event: InstanceAiEvent = {
type: 'confirmation-request',
runId: 'run-1',
agentId: 'agent-1',
payload: {
requestId: 'req-1',
toolCallId: 'tc-1',
toolName: 'call_agent',
args: {},
severity: 'warning',
message: 'Confirm action',
targetApproval: {
toolName: 'deep_action',
args: { visible: 'ordinary value', nested },
},
},
};
const [out] = redactor.processEvent(event);
if (out.type !== 'confirmation-request') throw new Error('Expected confirmation request');
const serializedArgs = JSON.stringify(out.payload.targetApproval?.args);
expect(serializedArgs).not.toContain(secret);
expect(serializedArgs).toContain('[REDACTED]');
expect(out.payload.targetApproval?.args).toMatchObject({ visible: 'ordinary value' });
});
it('logs a filtering summary with category counts and no values', () => {
const logger = createLogger();
const redactor = createRedactor(logger, { secrets: true, detect: ['email'] });
redactor.processEvent(
textDelta('key sk-ant-api03-aaaaaaaaaaaaaaaa and mail jane@example.com here'),
);
redactor.flush();
expect(logger.info).toHaveBeenCalledWith(
'Instance AI redacted sensitive content from agent output',
expect.objectContaining({
threadId: 'thread-1',
runId: 'run-1',
agentId: 'agent-1',
count: 2,
categories: { secret: 1, email: 1 },
}),
);
const meta = vi.mocked(logger.info).mock.calls[0][1];
expect(JSON.stringify(meta)).not.toContain('sk-ant-');
expect(JSON.stringify(meta)).not.toContain('jane@example.com');
});
it('does not log when nothing is redacted', () => {
const logger = createLogger();
const redactor = createRedactor(logger);
redactor.processEvent(textDelta('all clear, nothing sensitive here at all'));
redactor.flush();
expect(logger.info).not.toHaveBeenCalled();
});
describe('when disabled (options: false)', () => {
function createDisabled(logger = createLogger()) {
return new OutputRedactor({
logger,
threadId: 'thread-1',
runId: 'run-1',
agentId: 'agent-1',
options: false,
});
}
it('passes events through untouched, including secrets', () => {
const redactor = createDisabled();
const input = textDelta('your key is sk-ant-api03-aaaaaaaaaaaaaaaa here');
const emitted = [...redactor.processEvent(input), ...redactor.flush()];
expect(emitted).toEqual([input]);
});
it('does not log a filtering summary', () => {
const logger = createLogger();
const redactor = createDisabled(logger);
redactor.processEvent(textDelta('key sk-ant-api03-aaaaaaaaaaaaaaaa'));
redactor.flush();
expect(logger.info).not.toHaveBeenCalled();
});
});
});
@@ -1,4 +1,3 @@
import type { RedactionOptions } from '@n8n/agents';
import type { InstanceAiEvent } from '@n8n/api-types';
import type { InstanceAiEventBus } from '../event-bus/event-bus.interface';
@@ -35,12 +34,6 @@ export interface ConsumeWithHitlOptions {
resumeOptions?: Record<string, unknown>;
/** Native agent persistence owner for suspended sub-agent state. */
persistence?: { threadId: string; resourceId: string };
/**
* Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
* default policy, which on the durable-log path would persist redacted text
* — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
*/
outputRedaction?: RedactionOptions | false;
}
export interface ConsumeWithHitlResult {
@@ -97,7 +90,6 @@ export async function consumeStreamWithHitl(
eventBus: options.eventBus,
signal: options.abortSignal,
logger: options.logger,
outputRedaction: options.outputRedaction,
},
control: {
mode: 'auto',
@@ -131,12 +123,6 @@ export interface ConsumeStreamCascadingOptions {
logger: Logger;
threadId: string;
abortSignal: AbortSignal;
/**
* Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
* default policy, which on the durable-log path would persist redacted text
* — Instance AI passes `false` everywhere (raw-at-rest, INS-837).
*/
outputRedaction?: RedactionOptions | false;
}
export type ConsumeStreamCascadingResult =
@@ -185,7 +171,6 @@ export async function consumeStreamCascading(
eventBus: options.eventBus,
signal: options.abortSignal,
logger: options.logger,
outputRedaction: options.outputRedaction,
},
control: { mode: 'manual' },
});
@@ -1,286 +0,0 @@
import {
StreamingRedactor,
redactText,
redactDeep,
type RedactionCategory,
type RedactionOptions,
} from '@n8n/agents';
import type { InstanceAiEvent } from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import type { Logger } from '../logger';
/**
* Default output-filtering policy for Instance AI: redact known credential/
* secret patterns plus credit-card numbers. Other PII categories (`email`,
* `ssn-us`) are implemented but off by default until we decide which to enable.
*
* Instance AI's own streams pass `false` (raw-at-rest, INS-837), so this policy
* applies only to callers that opt in. When it does run it always redacts
* matches; the SDK's `GuardrailStrategy` also defines `block` and `warn`, but
* those are not implemented here.
*/
export const DEFAULT_OUTPUT_REDACTION_OPTIONS: RedactionOptions = {
secrets: true,
detect: ['credit-card'],
};
interface OutputRedactorContext {
logger: Logger;
threadId: string;
runId: string;
agentId: string;
/**
* Redaction policy: omit for the default policy, pass options to customise,
* or `false` to disable scanning entirely (events pass through untouched).
* NOTE: omission means ENABLED callers on a persistence path must pass
* `false` explicitly or the stored text is redacted.
*/
options?: RedactionOptions | false;
}
const MAX_TARGET_APPROVAL_ARG_DEPTH = 8;
const WITHHELD_TARGET_APPROVAL_ARG = '[REDACTED]';
function withholdDeepTargetApprovalArgs(value: unknown, depth = 0): unknown {
if (value === null || typeof value !== 'object') return value;
if (depth >= MAX_TARGET_APPROVAL_ARG_DEPTH) return WITHHELD_TARGET_APPROVAL_ARG;
if (Array.isArray(value)) {
return value.map((item) => withholdDeepTargetApprovalArgs(item, depth + 1));
}
return Object.fromEntries(
Object.entries(value).map(([key, item]) => [
key,
withholdDeepTargetApprovalArgs(item, depth + 1),
]),
);
}
type DeltaType = 'text-delta' | 'reasoning-delta';
interface Channel {
readonly type: DeltaType;
readonly redactor: StreamingRedactor;
responseId?: string;
}
/**
* Scans agent output events for secrets/PII before they reach the user and
* redacts matches in place.
*
* Text/reasoning deltas are streamed through a holdback-buffered redactor so a
* secret split across chunk boundaries is caught. Buffered text is released
* (with its original `responseId`) whenever a structural event (tool call,
* result, ), a new step boundary, or a channel switch (reasoning text or
* back) arrives secrets never span those boundaries so cross-channel
* event ordering and step grouping are preserved. Without the channel-switch
* drain, a reasoning tail held back by the redactor would be published after
* the text that followed it, rendering as a stray mid-sentence reasoning
* block in the UI. Tool results/errors are redacted one-shot.
*
* One instance per run. Call {@link processEvent} on each mapped event; it
* returns the ordered events to publish. Call {@link flush} when the active
* stream segment ends to release any remainder and log a filtering summary.
*/
export class OutputRedactor {
private readonly enabled: boolean;
private readonly options: RedactionOptions;
private readonly text: Channel;
private readonly reasoning: Channel;
private matches: RedactionCategory[] = [];
constructor(private readonly context: OutputRedactorContext) {
this.enabled = context.options !== false;
// When disabled the options are unused; default keeps the type concrete.
this.options =
context.options === false || context.options === undefined
? DEFAULT_OUTPUT_REDACTION_OPTIONS
: context.options;
this.text = { type: 'text-delta', redactor: new StreamingRedactor(this.options) };
this.reasoning = { type: 'reasoning-delta', redactor: new StreamingRedactor(this.options) };
}
/** Redact an outgoing event; returns the ordered events that should be published. */
processEvent(event: InstanceAiEvent): InstanceAiEvent[] {
if (!this.enabled) return [event];
if (event.type === 'text-delta') return this.processDelta(event, this.text, this.reasoning);
if (event.type === 'reasoning-delta') {
return this.processDelta(event, this.reasoning, this.text);
}
// Structural event: release any buffered text first so ordering is kept.
return [
...this.drainChannel(this.reasoning),
...this.drainChannel(this.text),
this.redactStructural(event),
];
}
/** Release buffered text for both channels and log a filtering summary. Call at segment end. */
flush(): InstanceAiEvent[] {
if (!this.enabled) return [];
const events = [...this.drainChannel(this.reasoning), ...this.drainChannel(this.text)];
this.logSummary();
return events;
}
private processDelta(
event: Extract<InstanceAiEvent, { type: DeltaType }>,
channel: Channel,
otherChannel: Channel,
): InstanceAiEvent[] {
const events: InstanceAiEvent[] = [];
// Channel switch: release the other channel's held-back tail first so
// true chronological order is kept. The model emits one channel at a
// time (a switch is a content-block boundary), so a secret never spans
// it and the drain is safe.
events.push(...this.drainChannel(otherChannel));
// A new step: release the previous step's text under its own responseId.
if (channel.responseId !== undefined && channel.responseId !== event.responseId) {
events.push(...this.drainChannel(channel));
}
channel.responseId = event.responseId;
const { text, matches } = channel.redactor.push(event.payload.text);
this.recordMatches(matches);
if (text) events.push(this.makeDelta(channel, text));
return events;
}
private drainChannel(channel: Channel): InstanceAiEvent[] {
const { text, matches } = channel.redactor.flush();
this.recordMatches(matches);
return text ? [this.makeDelta(channel, text)] : [];
}
private makeDelta(channel: Channel, text: string): InstanceAiEvent {
return {
type: channel.type,
runId: this.context.runId,
agentId: this.context.agentId,
...(channel.responseId ? { responseId: channel.responseId } : {}),
payload: { text },
};
}
private redactStructural(event: InstanceAiEvent): InstanceAiEvent {
if (event.type === 'tool-call') {
// Redact the model-generated args shown in the UI. This only touches the
// event payload, not the actual tool invocation (handled by the runtime).
const { value, matches } = redactDeep(event.payload.args, this.options);
this.recordMatches(matches);
return {
...event,
payload: { ...event.payload, args: isRecord(value) ? value : event.payload.args },
};
}
if (event.type === 'tool-result') {
const { value, matches } = redactDeep(event.payload.result, this.options);
this.recordMatches(matches);
return { ...event, payload: { ...event.payload, result: value } };
}
if (event.type === 'tool-error') {
return {
...event,
payload: { ...event.payload, error: this.redactString(event.payload.error) },
};
}
if (event.type === 'confirmation-request') {
return this.redactConfirmation(event);
}
return event;
}
/**
* Redact the human-readable content of a HITL confirmation card (message, intro,
* question/option labels, task/plan-item descriptions, and target-tool labels/args). Control and
* identifier fields `requestId`, `toolCallId`, `inputType`,
* `credentialRequests`, task `id`/`status`, plan `kind`/`deps`, etc. are
* left untouched so suspend/resume routing keeps working.
*/
private redactConfirmation(
event: Extract<InstanceAiEvent, { type: 'confirmation-request' }>,
): InstanceAiEvent {
const payload = event.payload;
const questions = payload.questions?.map((question) => ({
...question,
question: this.redactString(question.question),
...(question.options ? { options: question.options.map((o) => this.redactString(o)) } : {}),
}));
const tasks = payload.tasks
? {
...payload.tasks,
tasks: payload.tasks.tasks.map((task) => ({
...task,
description: this.redactString(task.description),
...(task.detail ? { detail: this.redactString(task.detail) } : {}),
})),
}
: undefined;
const planItems = payload.planItems?.map((item) => ({
...item,
title: this.redactString(item.title),
spec: this.redactString(item.spec),
}));
let targetApproval = payload.targetApproval;
if (targetApproval) {
const boundedArgs = withholdDeepTargetApprovalArgs(targetApproval.args);
const { value, matches } = redactDeep(boundedArgs, this.options);
this.recordMatches(matches);
targetApproval = {
...targetApproval,
toolName: this.redactString(targetApproval.toolName),
...(targetApproval.displayName
? { displayName: this.redactString(targetApproval.displayName) }
: {}),
args: value,
};
}
return {
...event,
payload: {
...payload,
message: this.redactString(payload.message),
...(payload.introMessage ? { introMessage: this.redactString(payload.introMessage) } : {}),
...(questions ? { questions } : {}),
...(tasks ? { tasks } : {}),
...(planItems ? { planItems } : {}),
...(targetApproval ? { targetApproval } : {}),
},
};
}
private redactString(text: string): string {
const { text: redacted, matches } = redactText(text, this.options);
this.recordMatches(matches);
return redacted;
}
private recordMatches(matches: Array<{ category: RedactionCategory }>): void {
for (const match of matches) this.matches.push(match.category);
}
/** Emit a single filtering-event log per segment — categories and counts only, never values. */
private logSummary(): void {
if (this.matches.length === 0) return;
const categories: Partial<Record<RedactionCategory, number>> = {};
for (const category of this.matches) {
categories[category] = (categories[category] ?? 0) + 1;
}
this.context.logger.info('Instance AI redacted sensitive content from agent output', {
threadId: this.context.threadId,
runId: this.context.runId,
agentId: this.context.agentId,
count: this.matches.length,
categories,
});
this.matches = [];
}
}
@@ -187,7 +187,6 @@ export function startEvalSetupAgentTask(
eventBus: context.eventBus,
logger: context.logger,
threadId: context.threadId,
outputRedaction: context.outputRedaction,
abortSignal: signal,
waitForConfirmation: context.waitForConfirmation,
drainCorrections,
@@ -62,8 +62,8 @@ function isStructuralTelemetryIdKey(key: string): boolean {
}
/**
* Telemetry/tracing redaction policy. Deliberately stricter than the
* user-facing output policy `DEFAULT_OUTPUT_REDACTION_OPTIONS`.
* Telemetry/tracing redaction policy: secrets plus every supported PII
* category, since traces egress to third-party tooling.
* `preserveUrlStructure`: whole-URL redaction destroyed traced workflow
* definitions without adding protection (secrets in URLs are caught first).
*/
-7
View File
@@ -5,7 +5,6 @@ import type {
BuiltTool,
CheckpointStore,
MemoryTaskUsageReport,
RedactionOptions,
RuntimeSkillSource,
ModelConfig as NativeModelConfig,
ScopedMemoryTaskEvent,
@@ -1699,12 +1698,6 @@ export interface OrchestrationContext {
checkpointStore?: CheckpointStore;
eventBus: InstanceAiEventBus;
logger: Logger;
/**
* Redaction policy. `false` disables scanning; OMITTING IT ENABLES the
* default policy, which on the durable-log path would persist redacted text
* Instance AI passes `false` everywhere (raw-at-rest, INS-837).
*/
outputRedaction?: RedactionOptions | false;
trackTelemetry?: (eventName: string, properties: Record<string, GenericValue>) => void;
/**
* Claim AI credits for a sub-agent stream segment. Wired by the host (cli);
@@ -26,10 +26,6 @@ export type InstanceAiEventMap = {
latencyMs: number;
trees: number;
};
/** History rendered from the message-derived fallback ladder instead of a renderable snapshot tree. */
'instance-ai-parser-fallback': {
count: number;
};
/** The interrupted-run sweep resolved a crashed run. */
'instance-ai-run-swept': {
outcome: 'interrupted' | 'crash-resumed';
@@ -123,12 +123,6 @@ export class PrometheusInstanceAiMetricsService implements PrometheusMetricsColl
buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5],
});
const parserFallbacksTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_parser_fallbacks_total`,
help: 'History messages rendered from the message-derived fallback ladder instead of a renderable snapshot tree.',
});
parserFallbacksTotal.inc(0);
const runsSweptTotal = new promClient.Counter({
name: `${this.config.prefix}instance_ai_runs_swept_total`,
help: 'Crashed Instance AI runs resolved by the interrupted-run sweep.',
@@ -157,9 +151,6 @@ export class PrometheusInstanceAiMetricsService implements PrometheusMetricsColl
this.eventService.on('instance-ai-history-folded', ({ latencyMs }) => {
historyFoldDuration.observe(latencyMs / 1000);
});
this.eventService.on('instance-ai-parser-fallback', ({ count }) => {
parserFallbacksTotal.inc(count);
});
this.eventService.on('instance-ai-run-swept', ({ outcome }) => {
runsSweptTotal.inc({ outcome }, 1);
});
@@ -1,5 +1,3 @@
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { InstanceAiMemoryService } from '../instance-ai-memory.service';
const mockListMessages = vi.fn();
@@ -26,7 +24,6 @@ const mockAgentMemory = {
};
// Mock GlobalConfig
const mockDbSnapshotStorage = { getForWindow: vi.fn().mockResolvedValue([]) };
const mockCheckpointRepository = { findActiveByThreadId: vi.fn().mockResolvedValue([]) };
interface LogRow {
@@ -81,7 +78,7 @@ function installLogDouble(rows: LogRow[] = []): void {
logRows.filter((row) => runIds.includes(row.runId)),
);
}
const mockDurableLogMetrics = { recordFoldRead: vi.fn(), notifyParserFallbacks: vi.fn() };
const mockDurableLogMetrics = { recordFoldRead: vi.fn() };
function createService(options: { threadTtlDays?: number } = {}): InstanceAiMemoryService {
const mockConfig = {
@@ -104,7 +101,6 @@ function createService(options: { threadTtlDays?: number } = {}): InstanceAiMemo
mockLogger as never,
mockConfig as never,
mockAgentMemory as never,
mockDbSnapshotStorage as never,
mockCheckpointRepository as never,
mockPendingConfirmationRepository as never,
mockEventLogRepository as never,
@@ -116,20 +112,6 @@ const mockPendingConfirmationRepository = {
findLiveRequestIds: vi.fn(async () => new Set<string>()),
};
function makeTree(overrides?: Partial<InstanceAiAgentNode>): InstanceAiAgentNode {
return {
agentId: 'agent-001',
role: 'orchestrator',
status: 'completed',
textContent: 'Done!',
reasoning: '',
toolCalls: [],
children: [],
timeline: [{ type: 'text', content: 'Done!' }],
...overrides,
};
}
function makeThread(id: string, updatedAt: string) {
return {
id,
@@ -144,92 +126,10 @@ function makeThread(id: string, updatedAt: string) {
describe('InstanceAiMemoryService.getRichMessages', () => {
beforeEach(() => {
vi.clearAllMocks();
mockDbSnapshotStorage.getForWindow.mockResolvedValue([]);
installLogDouble();
mockListMessages.mockResolvedValue({ messages: [] });
});
it('should return parsed rich messages with agent trees from snapshots', async () => {
const tree = makeTree();
mockListMessages.mockResolvedValue({
messages: [
{
id: 'msg-u',
role: 'user',
content: 'Hello',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
{
id: 'msg-a',
role: 'assistant',
content: [{ type: 'text', text: 'Done!' }],
createdAt: new Date('2026-01-01T00:00:01.000Z'),
},
],
});
mockDbSnapshotStorage.getForWindow.mockResolvedValue([
{
tree,
runId: 'run_abc',
createdAt: new Date('2026-01-01T00:00:01.000Z'),
updatedAt: new Date('2026-01-01T00:00:01.000Z'),
},
]);
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
expect(result.messages).toHaveLength(2);
expect(result.messages[0].role).toBe('user');
expect(result.messages[0].content).toBe('Hello');
expect(result.messages[1].role).toBe('assistant');
expect(result.messages[1].agentTree).toStrictEqual(tree);
expect(result.messages[1].runId).toBe('run_abc');
});
it('should return parsed messages with flat tree when no snapshots exist', async () => {
mockListMessages.mockResolvedValue({
messages: [
{
id: 'msg-u',
role: 'user',
content: 'Hi',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
{
id: 'msg-a',
role: 'assistant',
content: [
{ type: 'text', text: 'Here are your workflows' },
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'list-workflows',
input: {},
state: 'resolved',
output: { workflows: [] },
},
],
createdAt: new Date('2026-01-01T00:00:01.000Z'),
},
],
});
mockGetThread.mockResolvedValue({
id: 'thread-1',
title: 'Test',
metadata: {},
});
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
expect(result.messages).toHaveLength(2);
const assistant = result.messages[1];
expect(assistant.agentTree).toBeDefined();
expect(assistant.agentTree?.toolCalls).toHaveLength(1);
expect(assistant.agentTree?.toolCalls[0].toolName).toBe('list-workflows');
expect(assistant.agentTree?.toolCalls[0].isLoading).toBe(false);
});
it('should handle empty message list', async () => {
mockListMessages.mockResolvedValue({ messages: [] });
mockGetThread.mockResolvedValue({
@@ -244,95 +144,6 @@ describe('InstanceAiMemoryService.getRichMessages', () => {
expect(result.messages).toEqual([]);
});
it('surfaces in-flight checkpoint messages not yet committed to memory', async () => {
// A turn that suspended at HITL never gets `saveToMemory` called by the
// SDK. The inbound user message is persisted on receipt, but the
// intermediate assistant messages (and any pending tool-call) live only
// in `state.messageList.messages` until the turn resumes and completes.
// The /messages endpoint should surface them so a page reload doesn't
// drop in-flight artifacts.
mockListMessages.mockResolvedValue({ messages: [] });
mockCheckpointRepository.findActiveByThreadId.mockResolvedValueOnce([
{
key: 'run_abc',
runId: 'run_abc',
threadId: 'thread-1',
expiredAt: null,
state: {
messageList: {
messages: [
{
id: 'cp-user-1',
role: 'user',
content: [{ type: 'text', text: 'execute my workflow' }],
createdAt: '2026-01-01T00:00:00.000Z',
},
{
id: 'cp-assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'On it!' }],
createdAt: '2026-01-01T00:00:01.000Z',
},
],
},
},
createdAt: new Date('2026-01-01T00:00:01.000Z'),
updatedAt: new Date('2026-01-01T00:00:01.000Z'),
},
]);
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
expect(result.messages).toHaveLength(2);
expect(result.messages[0]).toMatchObject({ id: 'cp-user-1', role: 'user' });
expect(result.messages[0].content).toBe('execute my workflow');
expect(result.messages[1]).toMatchObject({ id: 'cp-assistant-1', role: 'assistant' });
});
it('prefers stored messages over checkpoint duplicates with the same id', async () => {
// When a previously suspended turn resumes and commits its messages to
// memory, the same IDs appear in both places. The stored row wins so
// any post-suspension edits the SDK made (e.g. final tool outcomes)
// are not regressed by the stale checkpoint copy.
mockListMessages.mockResolvedValue({
messages: [
{
id: 'msg-1',
role: 'user',
content: [{ type: 'text', text: 'final version' }],
createdAt: new Date('2026-01-01T00:00:00.000Z'),
},
],
});
mockCheckpointRepository.findActiveByThreadId.mockResolvedValueOnce([
{
key: 'run_abc',
expiredAt: null,
state: {
messageList: {
messages: [
{
id: 'msg-1',
role: 'user',
content: [{ type: 'text', text: 'stale checkpoint copy' }],
createdAt: '2026-01-01T00:00:00.000Z',
},
],
},
},
createdAt: new Date(),
updatedAt: new Date(),
},
]);
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
expect(result.messages).toHaveLength(1);
expect(result.messages[0].content).toBe('final version');
});
it('tolerates a missing or unreadable checkpoint store', async () => {
mockListMessages.mockResolvedValue({
messages: [
@@ -402,64 +213,11 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
beforeEach(() => {
vi.clearAllMocks();
mockDbSnapshotStorage.getForWindow.mockResolvedValue([]);
installLogDouble();
mockListMessages.mockResolvedValue({ messages: [userMessage, assistantMessage] });
});
it('derives the tree from the log even when the stored snapshot is degenerate', async () => {
// The stored snapshot was built over an evicted buffer: an empty
// cancelled tree with none of the run's work (the INS-595 bug family).
// Snapshot rows keep being written, but they are never read once the
// thread has log rows.
mockDbSnapshotStorage.getForWindow.mockResolvedValue([
{
tree: makeTree({ status: 'cancelled', textContent: '', timeline: [], toolCalls: [] }),
runId: 'run_abc',
createdAt: at,
updatedAt: at,
},
]);
setLogRows([
eventRow(
{
type: 'run-start',
runId: 'run_abc',
agentId: 'agent-001',
payload: { messageId: 'm-1' },
},
at,
),
...toolCallRows('run_abc', 3),
eventRow(
{
type: 'run-finish',
runId: 'run_abc',
agentId: 'agent-001',
payload: { status: 'completed' },
},
at,
),
]);
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
const assistant = result.messages[1];
expect(assistant.agentTree?.toolCalls).toHaveLength(3);
expect(assistant.agentTree?.toolCalls.map((tc) => tc.toolName)).toEqual([
'tool-1',
'tool-2',
'tool-3',
]);
expect(assistant.agentTree?.status).toBe('completed');
expect(mockDurableLogMetrics.recordFoldRead).toHaveBeenCalledWith(expect.any(Number), 1);
// The stored rows are not even loaded: the snapshot query only runs when
// the fold needs its fallback.
expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
});
it('renders a run that crashed before its snapshot was written', async () => {
it('derives the agent tree for a completed run from the log', async () => {
setLogRows([
eventRow(
{
@@ -565,16 +323,11 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
expect(result.messages[1].agentTree?.toolCalls).toHaveLength(1);
});
it('renders nothing for a fold emptied by exclusion instead of falling back to stored snapshots', async () => {
// The in-flight group is the thread's ONLY log content. Its completed
// sibling run_a has a stored snapshot that would survive the loader's
// exact-runId filter (only run_b is excluded) — falling back would
// resurrect exactly the in-flight group state the exclusion keeps out
// of history.
it('renders nothing for a fold emptied by exclusion', async () => {
// The in-flight group is the thread's ONLY log content. Excluding run_b
// poisons the whole group, so its completed sibling run_a must not derive
// a partial tree — the in-flight turn renders live via SSE, not history.
mockListMessages.mockResolvedValue({ messages: [userMessage] });
mockDbSnapshotStorage.getForWindow.mockResolvedValue([
{ tree: makeTree(), runId: 'run_a', createdAt: at, updatedAt: at },
]);
setLogRows([
eventRow(
{
@@ -611,7 +364,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
excludeRunIds: ['run_b'],
});
expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
});
@@ -651,7 +403,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
});
expect(mockDurableLogMetrics.recordFoldRead).not.toHaveBeenCalled();
expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
});
@@ -706,10 +457,9 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
// Only run_done's entry is derived, exactly as the driving main would.
expect(mockDurableLogMetrics.recordFoldRead).toHaveBeenCalledWith(expect.any(Number), 1);
expect(result.messages[1].agentTree?.toolCalls).toHaveLength(1);
expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
});
it('renders nothing when the only group is in flight on another main instead of falling back', async () => {
it('renders nothing when the only group is in flight on another main', async () => {
mockListMessages.mockResolvedValue({ messages: [userMessage] });
setLogRows([
eventRow(
@@ -735,15 +485,15 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
expect(result.messages).toHaveLength(1);
expect(result.messages[0].role).toBe('user');
});
it('keeps folding a HITL-suspended run without a run-finish', async () => {
// A suspended run legitimately never wrote its run-finish, but its turn
// must keep rendering: the fold entry pairs with the checkpoint-surfaced
// assistant message. The suspension is recognized by the run's own
// must keep rendering: with no assistant rows committed yet, the folded
// entry surfaces as a standalone assistant message carrying the
// confirmation card. The suspension is recognized by the run's own
// checkpoint — the same predicate that spares it from the interrupted-run
// sweep.
mockListMessages.mockResolvedValue({ messages: [userMessage] });
@@ -754,19 +504,7 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
hostRunId: 'run_susp',
threadId: 'thread-1',
expiredAt: null,
state: {
status: 'suspended',
messageList: {
messages: [
{
id: 'cp-assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'Confirm before I continue' }],
createdAt: '2026-01-01T00:00:01.000Z',
},
],
},
},
state: { status: 'suspended' },
createdAt: new Date('2026-01-01T00:00:01.000Z'),
updatedAt: new Date('2026-01-01T00:00:01.000Z'),
},
@@ -806,6 +544,7 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
expect(result.messages).toHaveLength(2);
const assistant = result.messages[1];
expect(assistant.role).toBe('assistant');
expect(assistant.agentTree?.toolCalls[0]?.confirmation?.requestId).toBe('req-1');
});
@@ -989,39 +728,32 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
expect(texts).toEqual(['one', 'two', 'three']);
});
it('keeps the stored snapshot tree for pre-log threads (no log rows)', async () => {
const tree = makeTree();
mockDbSnapshotStorage.getForWindow.mockResolvedValue([
{ tree, runId: 'run_abc', createdAt: at, updatedAt: at },
]);
it('renders messages without trees for pre-log threads (no log rows)', async () => {
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
expect(result.messages[1].agentTree).toStrictEqual(tree);
// No run-start facts -> nothing folds; the assistant message renders from
// its own content blocks only (no fold-provided runIds).
expect(result.messages).toHaveLength(2);
expect(result.messages[1].runIds).toBeUndefined();
expect(mockDurableLogMetrics.recordFoldRead).not.toHaveBeenCalled();
});
it('falls back to stored snapshots when the log read fails', async () => {
const tree = makeTree();
mockDbSnapshotStorage.getForWindow.mockResolvedValue([
{ tree, runId: 'run_abc', createdAt: at, updatedAt: at },
]);
it('renders messages without trees when the log read fails', async () => {
mockEventLogRepository.getRunStarts.mockRejectedValue(new Error('db down'));
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
expect(result.messages[1].agentTree).toStrictEqual(tree);
// Degrades to messages-without-trees rather than failing the page read.
expect(result.messages).toHaveLength(2);
expect(result.messages[1].runIds).toBeUndefined();
expect(mockDurableLogMetrics.recordFoldRead).not.toHaveBeenCalled();
});
it('falls back to stored snapshots when the log derives nothing renderable', async () => {
const tree = makeTree();
mockDbSnapshotStorage.getForWindow.mockResolvedValue([
{ tree, runId: 'run_abc', createdAt: at, updatedAt: at },
]);
it('renders messages without trees when the log derives nothing renderable', async () => {
// The log holds only lifecycle facts — no renderable work, so no orphan
// card is derived and the stored snapshots keep rendering.
// card is derived.
setLogRows([
eventRow(
{
@@ -1046,7 +778,8 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1');
expect(result.messages[1].agentTree).toStrictEqual(tree);
expect(result.messages).toHaveLength(2);
expect(result.messages[1].runIds).toBeUndefined();
expect(mockDurableLogMetrics.recordFoldRead).not.toHaveBeenCalled();
});
@@ -1159,30 +892,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
});
});
it('keeps the tree of the turn an older page ends on', async () => {
// The stored-snapshot path (durable log off) reads by snapshot
// createdAt, which lands just after the assistant row it pairs with.
const snapshotAt = new Date('2026-01-01T00:00:01.300Z');
const nextPageAt = new Date('2026-01-01T00:00:05.000Z');
const tree = makeTree();
mockListMessages.mockResolvedValue({
messages: [userMessage, assistantMessage],
newerBoundaryAt: nextPageAt,
});
mockDbSnapshotStorage.getForWindow.mockResolvedValue([
{ tree, runId: 'run_abc', createdAt: snapshotAt, updatedAt: snapshotAt },
]);
const service = createService();
const result = await service.getRichMessages('user-1', 'thread-1', { page: 1 });
expect(mockDbSnapshotStorage.getForWindow).toHaveBeenCalledWith('thread-1', {
since: userMessage.createdAt,
before: nextPageAt,
});
expect(result.messages[1].agentTree).toBe(tree);
});
it('hydrates nothing for an out-of-range older page', async () => {
// No message rows to pair a tree with, and no bounds to read one
// with: an unbounded read here would hydrate the whole thread to
@@ -1194,7 +903,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
const result = await service.getRichMessages('user-1', 'thread-1', { page: 3 });
expect(mockEventLogRepository.findRunIdsInWindow).not.toHaveBeenCalled();
expect(mockDbSnapshotStorage.getForWindow).not.toHaveBeenCalled();
expect(result.messages).toEqual([]);
});
@@ -1248,15 +956,6 @@ describe('InstanceAiMemoryService.getRichMessages — durable-log fold-on-read',
since: userMessage.createdAt,
});
});
it('windows the stored-snapshot path too', async () => {
const service = createService();
await service.getRichMessages('user-1', 'thread-1');
expect(mockDbSnapshotStorage.getForWindow).toHaveBeenCalledWith('thread-1', {
since: userMessage.createdAt,
});
});
});
});
@@ -100,7 +100,7 @@ vi.mock('@n8n/instance-ai', () => ({
}));
import type { Mock } from 'vitest';
import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types';
import type { InstanceAiEvent } from '@n8n/api-types';
import type { ManagedBackgroundTask, TerminalOutcome } from '@n8n/instance-ai';
import {
@@ -108,15 +108,6 @@ import {
type InstanceAiTerminalOutcomeServiceOptions,
} from '../instance-ai-terminal-outcome.service';
type SnapshotRow = {
tree: InstanceAiAgentNode;
runId: string;
messageGroupId?: string;
runIds?: string[];
langsmithRunId?: string;
langsmithTraceId?: string;
};
type Deps = {
eventBus: {
events: InstanceAiEvent[];
@@ -124,11 +115,6 @@ type Deps = {
getEventsForRuns: Mock;
publish: Mock;
};
dbSnapshotStorage: {
getLatest: Mock;
save: Mock;
updateLast: Mock;
};
telemetry: { track: Mock };
errorReporter: { report: Mock };
logger: { warn: Mock; debug: Mock; error: Mock };
@@ -136,7 +122,6 @@ type Deps = {
suspendedThreads: { dropPendingConfirmationsForThread: Mock };
tracing: { finalizeRunTracing: Mock; buildMessageTraceMetadata: Mock };
publishRunFinish: Mock;
saveAgentTreeSnapshot: Mock;
};
function makeTerminalOutcome(overrides: Partial<TerminalOutcome> = {}): TerminalOutcome {
@@ -155,20 +140,7 @@ function makeTerminalOutcome(overrides: Partial<TerminalOutcome> = {}): Terminal
};
}
function makeAgentTree(): InstanceAiAgentNode {
return {
agentId: 'agent-001',
role: 'orchestrator',
status: 'completed',
textContent: 'Initial response',
reasoning: '',
toolCalls: [],
children: [],
timeline: [{ type: 'text', content: 'Initial response' }],
};
}
function createService(snapshotTree?: InstanceAiAgentNode): {
function createService(): {
service: InstanceAiTerminalOutcomeService;
deps: Deps;
} {
@@ -182,16 +154,6 @@ function createService(snapshotTree?: InstanceAiAgentNode): {
events.push(event);
}),
},
dbSnapshotStorage: {
getLatest: vi.fn(
async (): Promise<SnapshotRow | undefined> =>
snapshotTree
? { tree: snapshotTree, runId: 'run-1', messageGroupId: 'group-1', runIds: ['run-1'] }
: undefined,
),
save: vi.fn(async () => {}),
updateLast: vi.fn(async () => {}),
},
telemetry: { track: vi.fn() },
errorReporter: { report: vi.fn() },
logger: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
@@ -214,12 +176,10 @@ function createService(snapshotTree?: InstanceAiAgentNode): {
} as InstanceAiEvent);
},
),
saveAgentTreeSnapshot: vi.fn(async () => {}),
};
const options = {
eventBus: deps.eventBus,
dbSnapshotStorage: deps.dbSnapshotStorage,
agentMemory: {},
telemetry: deps.telemetry,
errorReporter: deps.errorReporter,
@@ -228,7 +188,6 @@ function createService(snapshotTree?: InstanceAiAgentNode): {
suspendedThreads: deps.suspendedThreads,
tracing: deps.tracing,
publishRunFinish: deps.publishRunFinish,
saveAgentTreeSnapshot: deps.saveAgentTreeSnapshot,
} as unknown as InstanceAiTerminalOutcomeServiceOptions;
return { service: new InstanceAiTerminalOutcomeService(options), deps };
@@ -242,104 +201,13 @@ beforeEach(() => {
});
describe('InstanceAiTerminalOutcomeService — terminal outcome replay', () => {
it('replays undelivered background outcomes into the persisted agent tree', async () => {
it('publishes recovered background outcomes and marks them delivered', async () => {
const outcome = makeTerminalOutcome();
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService(makeAgentTree());
await service.replayUndeliveredTerminalOutcomes('thread-a');
expect(deps.dbSnapshotStorage.updateLast).toHaveBeenCalledTimes(1);
const updatedTree = deps.dbSnapshotStorage.updateLast.mock.calls[0][1] as InstanceAiAgentNode;
expect(updatedTree.textContent).toContain(outcome.userFacingMessage);
expect(updatedTree.timeline).toContainEqual({
type: 'text',
content: outcome.userFacingMessage,
responseId: `background-outcome:${outcome.id}`,
});
expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledWith(
'thread-a',
outcome.id,
expect.any(String),
);
expect(deps.eventBus.publish).not.toHaveBeenCalled();
});
it('publishes recovered background outcomes when replaying for SSE delivery', async () => {
const outcome = makeTerminalOutcome();
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService(makeAgentTree());
await service.replayUndeliveredTerminalOutcomes('thread-a', { delivery: 'event' });
expect(deps.dbSnapshotStorage.updateLast).toHaveBeenCalledTimes(1);
expect(deps.eventBus.publish).toHaveBeenCalledWith('thread-a', {
type: 'text-block',
runId: outcome.runId,
agentId: 'orchestrator-run-1',
responseId: `background-outcome:${outcome.id}`,
payload: { text: outcome.userFacingMessage },
});
expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledWith(
'thread-a',
outcome.id,
expect.any(String),
);
});
it('deduplicates replay by response id only', async () => {
const outcome = makeTerminalOutcome({ id: 'group-1:task-2:completed' });
const tree = makeAgentTree();
tree.textContent = `${tree.textContent}\n\n${outcome.userFacingMessage}`;
tree.timeline.push({
type: 'text',
content: outcome.userFacingMessage,
responseId: 'background-outcome:different-id',
});
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService(tree);
await service.replayUndeliveredTerminalOutcomes('thread-a');
const updatedTree = deps.dbSnapshotStorage.updateLast.mock.calls[0][1] as InstanceAiAgentNode;
expect(
updatedTree.timeline.filter(
(entry) => entry.type === 'text' && entry.content === outcome.userFacingMessage,
),
).toHaveLength(2);
expect(updatedTree.timeline).toContainEqual({
type: 'text',
content: outcome.userFacingMessage,
responseId: `background-outcome:${outcome.id}`,
});
});
it('creates a snapshot when replay has no prior agent tree', async () => {
const outcome = makeTerminalOutcome({ status: 'failed' });
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService();
await service.replayUndeliveredTerminalOutcomes('thread-a');
expect(deps.dbSnapshotStorage.save).toHaveBeenCalledTimes(1);
const savedTree = deps.dbSnapshotStorage.save.mock.calls[0][1] as InstanceAiAgentNode;
expect(savedTree.status).toBe('error');
expect(savedTree.textContent).toBe(outcome.userFacingMessage);
expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledWith(
'thread-a',
outcome.id,
expect.any(String),
);
});
it('publishes the deterministic line when snapshot replay fails', async () => {
const outcome = makeTerminalOutcome();
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService(makeAgentTree());
deps.dbSnapshotStorage.updateLast.mockRejectedValue(new Error('storage unavailable'));
await service.replayUndeliveredTerminalOutcomes('thread-a', { delivery: 'event' });
expect(deps.eventBus.publish).toHaveBeenCalledWith('thread-a', {
type: 'text-block',
runId: outcome.runId,
@@ -347,7 +215,80 @@ describe('InstanceAiTerminalOutcomeService — terminal outcome replay', () => {
responseId: `background-outcome:${outcome.id}`,
payload: { text: outcome.userFacingMessage },
});
expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledWith(
'thread-a',
outcome.id,
expect.any(String),
);
expect(deps.telemetry.track).toHaveBeenCalledWith(
'instance_ai_terminal_response_decision',
expect.objectContaining({ source: 'terminal_outcome_replay', action: 'replay_event' }),
);
});
it('deduplicates replay by response id only', async () => {
const outcome = makeTerminalOutcome({ id: 'group-1:task-2:completed' });
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService();
// Same text under a different response id must not suppress the replay.
deps.eventBus.events.push({
type: 'text-block',
runId: outcome.runId,
agentId: 'orchestrator-run-1',
responseId: 'background-outcome:different-id',
payload: { text: outcome.userFacingMessage },
} as InstanceAiEvent);
await service.replayUndeliveredTerminalOutcomes('thread-a');
expect(deps.eventBus.publish).toHaveBeenCalledTimes(1);
expect(deps.eventBus.publish).toHaveBeenCalledWith(
'thread-a',
expect.objectContaining({ responseId: `background-outcome:${outcome.id}` }),
);
// A second replay finds the exact response id already emitted and skips it.
await service.replayUndeliveredTerminalOutcomes('thread-a');
expect(deps.eventBus.publish).toHaveBeenCalledTimes(1);
expect(deps.telemetry.track).toHaveBeenCalledWith(
'instance_ai_terminal_response_decision',
expect.objectContaining({ source: 'terminal_outcome_replay', action: 'already-emitted' }),
);
});
it('leaves the outcome undelivered when publishing the line fails', async () => {
const outcome = makeTerminalOutcome();
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService();
deps.eventBus.publish.mockImplementation(() => {
throw new Error('bus unavailable');
});
await service.replayUndeliveredTerminalOutcomes('thread-a');
expect(terminalOutcomeStorageMock.markDelivered).not.toHaveBeenCalled();
expect(deps.logger.warn).toHaveBeenCalledWith(
'Failed to replay Instance AI terminal outcome',
expect.objectContaining({ threadId: 'thread-a', runId: outcome.runId }),
);
});
it('leaves the outcome undelivered when the drain drops the published line', async () => {
const outcome = makeTerminalOutcome();
terminalOutcomeStorageMock.getUndelivered.mockResolvedValue([outcome]);
const { service, deps } = createService();
// The publish enqueues without throwing, but the line never reaches the
// log — the shape of a dropped drain batch.
deps.eventBus.publish.mockImplementation(() => {});
await service.replayUndeliveredTerminalOutcomes('thread-a');
expect(terminalOutcomeStorageMock.markDelivered).not.toHaveBeenCalled();
expect(deps.telemetry.track).not.toHaveBeenCalledWith(
'instance_ai_terminal_response_decision',
expect.anything(),
);
});
it('checks persisted outcomes on repeated replay calls', async () => {
@@ -380,7 +321,7 @@ describe('InstanceAiTerminalOutcomeService — background outcome recording', ()
}
it('persists, publishes, and marks the outcome delivered on success', async () => {
const { service, deps } = createService(makeAgentTree());
const { service, deps } = createService();
await service.recordBackgroundTerminalOutcome(makeTask());
@@ -392,13 +333,12 @@ describe('InstanceAiTerminalOutcomeService — background outcome recording', ()
payload: { text: 'The background workflow-builder task finished.' },
}),
);
expect(deps.dbSnapshotStorage.updateLast).toHaveBeenCalledTimes(1);
expect(terminalOutcomeStorageMock.markDelivered).toHaveBeenCalledTimes(1);
});
it('keeps the outcome pending and replays it later when persistence fails', async () => {
terminalOutcomeStorageMock.upsert.mockRejectedValueOnce(new Error('db down'));
const { service, deps } = createService(makeAgentTree());
const { service, deps } = createService();
await service.recordBackgroundTerminalOutcome(makeTask());
@@ -412,16 +352,33 @@ describe('InstanceAiTerminalOutcomeService — background outcome recording', ()
await service.replayUndeliveredTerminalOutcomes('thread-a');
expect(terminalOutcomeStorageMock.markDelivered).not.toHaveBeenCalled();
});
it('does not mark the outcome delivered when the drain drops the line', async () => {
const { service, deps } = createService();
deps.eventBus.publish.mockImplementation(() => {});
await service.recordBackgroundTerminalOutcome(makeTask());
expect(terminalOutcomeStorageMock.upsert).toHaveBeenCalledTimes(1);
expect(terminalOutcomeStorageMock.markDelivered).not.toHaveBeenCalled();
expect(deps.telemetry.track).toHaveBeenCalledWith(
'instance_ai_terminal_outcome_persistence_failure',
expect.objectContaining({ phase: 'event' }),
);
expect(deps.telemetry.track).not.toHaveBeenCalledWith(
'instance_ai_terminal_response_decision',
expect.anything(),
);
});
});
describe('InstanceAiTerminalOutcomeService — durable-log outcome lines', () => {
it('publishes the outcome line as a persisted text-block, not a trailing delta', async () => {
// A trailing delta would race the coalescer's idle flush on an immediate
// page reload; a text-block is persisted before it is emitted live.
const { deps } = createService(makeAgentTree());
const { deps } = createService();
const service = new InstanceAiTerminalOutcomeService({
eventBus: deps.eventBus,
dbSnapshotStorage: deps.dbSnapshotStorage,
agentMemory: {},
telemetry: deps.telemetry,
logger: deps.logger,
@@ -429,7 +386,6 @@ describe('InstanceAiTerminalOutcomeService — durable-log outcome lines', () =>
suspendedThreads: deps.suspendedThreads,
tracing: deps.tracing,
publishRunFinish: deps.publishRunFinish,
saveAgentTreeSnapshot: vi.fn(async () => {}),
} as never);
await service.recordBackgroundTerminalOutcome({
@@ -547,7 +503,6 @@ describe('InstanceAiTerminalOutcomeService — terminal response guard wiring',
threadId: 'thread-a',
runId: 'run-1',
abortController,
snapshotStorage: {} as never,
});
expect(finalization.status).toBe('error');
@@ -557,7 +512,6 @@ describe('InstanceAiTerminalOutcomeService — terminal response guard wiring',
'thread-a',
);
expect(abortController.signal.aborted).toBe(true);
expect(deps.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
expect(deps.eventBus.events.at(-1)).toMatchObject({
type: 'run-finish',
payload: { status: 'error' },
@@ -19,9 +19,9 @@ vi.mock('@n8n/instance-ai', () => ({
import {
InstanceAiTracingService,
type InstanceAiTracingAiService,
type InstanceAiTracingEventLog,
type InstanceAiTracingEventReader,
type InstanceAiTracingRunState,
type InstanceAiTracingSnapshotStorage,
} from '../tracing';
type FakeTraceRun = {
@@ -54,7 +54,7 @@ function createService(
logger?: Partial<Logger>;
eventReader?: Partial<InstanceAiTracingEventReader>;
runState?: Partial<InstanceAiTracingRunState>;
dbSnapshotStorage?: Partial<InstanceAiTracingSnapshotStorage>;
eventLog?: Partial<InstanceAiTracingEventLog>;
aiService?: Partial<InstanceAiTracingAiService>;
} = {},
) {
@@ -67,9 +67,9 @@ function createService(
attachTracing: vi.fn(),
...overrides.runState,
};
const dbSnapshotStorage: InstanceAiTracingSnapshotStorage = {
const eventLog: InstanceAiTracingEventLog = {
findLangsmithAnchor: vi.fn(async () => undefined),
...overrides.dbSnapshotStorage,
...overrides.eventLog,
};
const aiService: InstanceAiTracingAiService = {
isProxyEnabled: vi.fn(() => false),
@@ -81,11 +81,11 @@ function createService(
logger,
eventReader,
runState,
dbSnapshotStorage,
eventLog,
aiService,
});
return { service, logger, eventReader, runState, dbSnapshotStorage, aiService };
return { service, logger, eventReader, runState, eventLog, aiService };
}
describe('InstanceAiTracingService', () => {
@@ -313,7 +313,7 @@ describe('InstanceAiTracingService', () => {
describe('submitLangsmithFeedback', () => {
it('skips submission when no LangSmith anchor exists', async () => {
const findLangsmithAnchor = vi.fn(async () => undefined);
const { service } = createService({ dbSnapshotStorage: { findLangsmithAnchor } });
const { service } = createService({ eventLog: { findLangsmithAnchor } });
await service.submitLangsmithFeedback(
{ id: 'user-1' } as unknown as User,
@@ -332,7 +332,7 @@ describe('InstanceAiTracingService', () => {
langsmithTraceId: 'ls-trace',
}));
const { service } = createService({
dbSnapshotStorage: { findLangsmithAnchor },
eventLog: { findLangsmithAnchor },
aiService: { isProxyEnabled: vi.fn(() => false) },
});
@@ -424,79 +424,7 @@ describe('InstanceAiController', () => {
expect(scopeOf('events')).toEqual({ scope: 'instanceAi:message', globalOnly: true });
});
it('should bootstrap run-sync from the richer persisted snapshot when live events are incomplete', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
eventLog.getEventsAfter.mockResolvedValue([]);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
backgroundTasks: [],
} as never);
instanceAiService.getMessageGroupId.mockReturnValue('mg-1');
instanceAiService.getRunIdsForMessageGroup.mockReturnValue(['run-1']);
eventLog.getEventsForRuns.mockResolvedValue([
{
type: 'run-start',
runId: 'run-1',
agentId: 'agent-root',
payload: { messageId: 'msg-1', messageGroupId: 'mg-1' },
},
] as never);
memoryService.getLatestRunSnapshot.mockResolvedValue({
runId: 'run-1',
messageGroupId: 'mg-1',
runIds: ['run-1'],
tree: {
agentId: 'agent-root',
role: 'orchestrator',
status: 'active',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
planItems: [
{
id: 'task-1',
title: 'Build workflow',
kind: 'build-workflow',
spec: 'Create the workflow',
deps: [],
},
],
},
});
const sseRes = mock<Response & { flush?: () => void }>({
setHeader: vi.fn(),
flushHeaders: vi.fn(),
write: vi.fn(),
end: vi.fn(),
flush: vi.fn(),
});
eventBus.subscribe.mockReturnValue(vi.fn());
const sseReq = mock<AuthenticatedRequest>({
user: { id: USER_ID },
headers: {},
once: vi.fn(),
});
await controller.events(sseReq, sseRes, THREAD_ID, { lastEventId: undefined } as never);
expect(instanceAiService.replayUndeliveredTerminalOutcomes).toHaveBeenCalledWith(THREAD_ID, {
delivery: 'event',
});
const runSyncFrame = (sseRes.write as Mock).mock.calls
.map(([frame]) => String(frame))
.find((frame) => frame.startsWith('event: run-sync'));
expect(runSyncFrame).toContain('"agent-root"');
expect(runSyncFrame).toContain('"planItems"');
});
it('should replay events that arrive while bootstrap snapshot fetches are in flight', async () => {
it('should replay events that arrive while bootstrap log reads are in flight', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
@@ -505,7 +433,6 @@ describe('InstanceAiController', () => {
} as never);
instanceAiService.getMessageGroupId.mockReturnValue('mg-1');
instanceAiService.getRunIdsForMessageGroup.mockReturnValue(['run-1']);
eventLog.getEventsForRuns.mockResolvedValue([]);
eventLog.getEventsAfter.mockResolvedValue([]);
let subscribeHandler: ((stored: { id: number; event: unknown }) => void) | undefined;
@@ -516,17 +443,17 @@ describe('InstanceAiController', () => {
return vi.fn();
});
// While the persisted snapshot is being fetched, a relayed event arrives:
// While the run-sync tree read is in flight, a relayed event arrives:
// the early subscription keeps it flowing into the store, and the replay
// after the await must pick it up exactly once.
const midAwaitEvent = {
id: 7,
event: { type: 'run-finish', runId: 'run-1', agentId: 'a1', payload: {} },
};
memoryService.getLatestRunSnapshot.mockImplementation(async () => {
eventLog.getEventsForRuns.mockImplementation(async () => {
subscribeHandler!(midAwaitEvent);
eventLog.getEventsAfter.mockResolvedValue([midAwaitEvent] as never);
return undefined;
return [];
});
const sseRes = mock<Response & { flush?: () => void }>({
@@ -547,7 +474,7 @@ describe('InstanceAiController', () => {
// Subscription must be registered before the async bootstrap starts, so
// sibling mains keep relaying events for this thread during the awaits.
expect(eventBus.subscribe.mock.invocationCallOrder[0]).toBeLessThan(
memoryService.getLatestRunSnapshot.mock.invocationCallOrder[0],
(eventLog.getEventsAfter as Mock).mock.invocationCallOrder[0],
);
const eventFrames = (sseRes.write as Mock).mock.calls
@@ -566,12 +493,13 @@ describe('InstanceAiController', () => {
instanceAiService.getMessageGroupId.mockReturnValue('mg-1');
instanceAiService.getRunIdsForMessageGroup.mockReturnValue(['run-1']);
eventLog.getEventsForRuns.mockResolvedValue([]);
eventLog.getEventsAfter.mockResolvedValue([
{ id: 1, event: { type: 'text-delta', runId: 'run-1', agentId: 'a1', payload: {} } },
] as never);
const unsubscribe = vi.fn();
eventBus.subscribe.mockReturnValue(unsubscribe);
const unsubscribers: Array<ReturnType<typeof vi.fn>> = [];
eventBus.subscribe.mockImplementation(() => {
const unsubscribe = vi.fn();
unsubscribers.push(unsubscribe);
return unsubscribe;
});
let closeHandler: (() => void) | undefined;
const sseReq = mock<AuthenticatedRequest>({
@@ -589,15 +517,21 @@ describe('InstanceAiController', () => {
flush: vi.fn(),
});
// The client disconnects while the persisted snapshot is being fetched.
memoryService.getLatestRunSnapshot.mockImplementation(async () => {
// The client disconnects while the replay read is in flight.
eventLog.getEventsAfter.mockImplementation(async () => {
closeHandler!();
return undefined;
return [
{ id: 1, event: { type: 'text-delta', runId: 'run-1', agentId: 'a1', payload: {} } },
] as never;
});
await controller.events(sseReq, sseRes, THREAD_ID, { lastEventId: undefined } as never);
expect(unsubscribe).toHaveBeenCalledTimes(1);
// Both the live subscription (via the close handler) and the temporary
// buffering subscription (via the bootstrap finally) are removed.
expect(unsubscribers).toHaveLength(2);
expect(unsubscribers[0]).toHaveBeenCalledTimes(1);
expect(unsubscribers[1]).toHaveBeenCalledTimes(1);
expect(sseRes.write).not.toHaveBeenCalled();
});
@@ -2126,7 +2060,6 @@ describe('InstanceAiController — durable-log SSE replay', () => {
it('delivers events that land during the run-sync tree reads', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
memoryService.getLatestRunSnapshot.mockResolvedValue(undefined);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
@@ -2192,7 +2125,6 @@ describe('InstanceAiController — durable-log SSE replay', () => {
it('serves the open streamed segment as one ephemeral delta frame and skips its buffered deltas', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
memoryService.getLatestRunSnapshot.mockResolvedValue(undefined);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
@@ -2427,7 +2359,6 @@ describe('InstanceAiController — durable-log SSE replay', () => {
it('does not re-apply a gap block already folded into a delivered run-sync tree', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
memoryService.getLatestRunSnapshot.mockResolvedValue(undefined);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
@@ -2536,7 +2467,6 @@ describe('InstanceAiController — durable-log SSE replay', () => {
it('stops the bootstrap when the client disconnects during a durable read', async () => {
memoryService.checkThreadOwnership.mockResolvedValue('owned');
memoryService.getLatestRunSnapshot.mockResolvedValue(undefined);
instanceAiService.getThreadStatus.mockReturnValue({
hasActiveRun: true,
isSuspended: false,
@@ -185,13 +185,11 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => {
type FinalizeInternals = {
publishRunFinish: ReturnType<typeof vi.fn>;
emitRunMetrics: ReturnType<typeof vi.fn>;
saveAgentTreeSnapshot: ReturnType<typeof vi.fn>;
refineTitleIfNeeded: ReturnType<typeof vi.fn>;
finalizeRun: (
threadId: string,
runId: string,
status: 'completed' | 'cancelled' | 'errored',
snapshotStorage: unknown,
options?: { userId?: string; modelId?: ModelConfig },
) => Promise<void>;
};
@@ -202,7 +200,6 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => {
const service = Object.create(InstanceAiService.prototype) as unknown as FinalizeInternals;
service.publishRunFinish = vi.fn();
service.emitRunMetrics = vi.fn();
service.saveAgentTreeSnapshot = vi.fn(async () => {});
service.refineTitleIfNeeded = vi.fn(async () => {});
return service;
}
@@ -210,7 +207,7 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => {
it('refines the title when a completed run supplies userId and modelId', async () => {
const service = createService();
await service.finalizeRun('thread-1', 'run-1', 'completed', {}, { userId: 'user-1', modelId });
await service.finalizeRun('thread-1', 'run-1', 'completed', { userId: 'user-1', modelId });
expect(service.refineTitleIfNeeded).toHaveBeenCalledWith('thread-1', 'user-1', modelId);
});
@@ -218,7 +215,7 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => {
it('skips refinement when modelId is missing', async () => {
const service = createService();
await service.finalizeRun('thread-1', 'run-1', 'completed', {}, { userId: 'user-1' });
await service.finalizeRun('thread-1', 'run-1', 'completed', { userId: 'user-1' });
expect(service.refineTitleIfNeeded).not.toHaveBeenCalled();
});
@@ -201,13 +201,12 @@ vi.mock('@/permissions.ee/check-access', () => ({
}));
import type { MemoryTaskUsageReport, ScopedMemoryTaskEvent } from '@n8n/agents';
import type { InstanceAiAgentNode, InstanceAiEvent } from '@n8n/api-types';
import type { InstanceAiEvent } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import type { InstanceAiConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import { Container } from '@n8n/di';
import {
buildAgentTreeFromEvents,
createAllTools,
createLazyRuntimeWorkspace,
createLazyWorkspaceRuntimeSkillSource,
@@ -265,7 +264,6 @@ type BackgroundTaskFollowUpServiceInternals = {
spawnBackgroundTask: (
runId: string,
opts: SpawnBackgroundTaskOptions,
snapshotStorage: unknown,
messageGroupIdOverride?: string,
) => SpawnBackgroundTaskResult;
backgroundTasks: {
@@ -304,15 +302,6 @@ type BackgroundTaskFollowUpServiceInternals = {
terminalOutcome: {
recordBackgroundTerminalOutcome: MockedFunction<(task: ManagedBackgroundTask) => Promise<void>>;
};
saveAgentTreeSnapshot: MockedFunction<
(
threadId: string,
runId: string,
snapshotStorage: unknown,
isUpdate?: boolean,
overrideMessageGroupId?: string,
) => Promise<void>
>;
startInternalFollowUpRun: MockedFunction<
(
user: User,
@@ -401,15 +390,6 @@ function createBackgroundTaskFollowUpService({
service.terminalOutcome = {
recordBackgroundTerminalOutcome: vi.fn(async (_task: ManagedBackgroundTask) => {}),
};
service.saveAgentTreeSnapshot = vi.fn(
async (
_threadId: string,
_runId: string,
_snapshotStorage: unknown,
_isUpdate?: boolean,
_overrideMessageGroupId?: string,
) => {},
);
service.startInternalFollowUpRun = vi.fn(
async (
_user: User,
@@ -718,7 +698,6 @@ type TerminalGuardOrderServiceInternals = {
status: 'completed' | 'cancelled' | 'errored',
reason?: string,
) => void;
saveAgentTreeSnapshot: Mock;
backgroundTasks: { getRunningTasks: Mock; getRunningTasksByParentCheckpoint?: Mock };
temporaryWorkflowService: { reapForRun: Mock };
creditService: { claimRunUsage: Mock; ensureQuotaLockApplied: Mock };
@@ -742,7 +721,6 @@ type TerminalGuardOrderServiceInternals = {
toolCallId: string;
signal: AbortSignal;
abortController: AbortController;
snapshotStorage: unknown;
tracing?: InstanceAiTraceContext;
orchestrationContext?: { tracing?: unknown };
checkpoint?: { isCheckpointFollowUp: boolean; checkpointTaskId: string };
@@ -754,31 +732,6 @@ type TerminalGuardOrderServiceInternals = {
) => Promise<void>;
};
type SnapshotServiceInternals = {
saveAgentTreeSnapshot: (
threadId: string,
runId: string,
snapshotStorage: {
getLatest: Mock;
save: Mock;
updateLast: Mock;
},
isUpdate?: boolean,
overrideMessageGroupId?: string,
) => Promise<void>;
runState: {
getMessageGroupId: Mock;
getRunIdsForMessageGroup: Mock;
};
eventBus: {
getEventsForRun: Mock;
getEventsForRuns: Mock;
};
eventLog: { flush: Mock; getEventsForRuns: Mock };
tracing: { getTraceContext: Mock };
logger: { warn: Mock };
};
function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
const events: InstanceAiEvent[] = [];
const service = Object.create(
@@ -821,7 +774,6 @@ function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
};
service.threadPushRef = new Map();
service.pendingBrowserCredentialSetups = new Map();
service.saveAgentTreeSnapshot = vi.fn(async () => {});
service.backgroundTasks = { getRunningTasks: vi.fn(() => []) };
service.temporaryWorkflowService = { reapForRun: vi.fn(async () => []) };
service.creditService = {
@@ -835,7 +787,6 @@ function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
service.terminalOutcome = new InstanceAiTerminalOutcomeService({
eventBus: service.eventBus,
dbSnapshotStorage: {},
agentMemory: {},
telemetry: service.telemetry,
errorReporter: service.instanceAiErrorReporter,
@@ -855,42 +806,10 @@ function createTerminalGuardOrderService(): TerminalGuardOrderServiceInternals {
payload: { status: status === 'errored' ? 'error' : status },
} as InstanceAiEvent);
},
saveAgentTreeSnapshot: async (threadId: string, runId: string, snapshotStorage: unknown) => {
await service.saveAgentTreeSnapshot(threadId, runId, snapshotStorage);
},
} as unknown as InstanceAiTerminalOutcomeServiceOptions);
return service;
}
function createSnapshotService(): SnapshotServiceInternals {
const service = Object.create(InstanceAiService.prototype) as unknown as SnapshotServiceInternals;
service.runState = {
getMessageGroupId: vi.fn(() => undefined),
getRunIdsForMessageGroup: vi.fn(() => []),
};
service.eventBus = {
getEventsForRun: vi.fn(() => []),
getEventsForRuns: vi.fn(() => []),
};
service.eventLog = { flush: vi.fn(async () => {}), getEventsForRuns: vi.fn(async () => []) };
service.tracing = { getTraceContext: vi.fn(() => undefined) };
service.logger = { warn: vi.fn() };
return service;
}
function makeAgentTree(): InstanceAiAgentNode {
return {
agentId: 'agent-001',
role: 'orchestrator',
status: 'completed',
textContent: 'Initial response',
reasoning: '',
toolCalls: [],
children: [],
timeline: [{ type: 'text', content: 'Initial response' }],
};
}
describe('InstanceAiService — runtime workspace setup', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -923,7 +842,6 @@ describe('InstanceAiService — runtime workspace setup', () => {
abortSignal: AbortSignal,
) => Promise<{
orchestrationContext: {
outputRedaction?: unknown;
workspace?: unknown;
runtimeSkills?: {
registry: { skillsHash: string; skills: Array<{ id: string }> };
@@ -955,7 +873,6 @@ describe('InstanceAiService — runtime workspace setup', () => {
ensureThreadExists: Mock;
agentMemory: unknown;
dbIterationLogStorage: unknown;
dbSnapshotStorage: unknown;
checkpointStore: unknown;
instanceAiConfig: Record<string, never>;
defaultTimeZone: string;
@@ -1006,7 +923,6 @@ describe('InstanceAiService — runtime workspace setup', () => {
service.ensureThreadExists = vi.fn(async () => {});
service.agentMemory = { getThreadProjectId: vi.fn(async () => 'project-1') };
service.dbIterationLogStorage = {};
service.dbSnapshotStorage = {};
service.checkpointStore = {};
service.instanceAiConfig = {};
service.defaultTimeZone = 'UTC';
@@ -1066,10 +982,6 @@ describe('InstanceAiService — runtime workspace setup', () => {
new AbortController().signal,
);
// OutputRedactor treats an OMITTED policy as ENABLED (`options !== false`),
// so this must stay an explicit false or every stream is scanned and the
// durable log stores redacted text instead of raw (INS-837).
expect(environment.orchestrationContext.outputRedaction).toBe(false);
expect(createLazyRuntimeWorkspace).toHaveBeenCalledTimes(2);
expect(createLazyRuntimeWorkspace).toHaveBeenNthCalledWith(
2,
@@ -1298,7 +1210,6 @@ describe('InstanceAiService — background task auto-follow-up', () => {
role: 'workflow-builder',
run: async () => 'done',
},
{},
'group-1',
);
await getSpawnOptions().onSettled?.(task);
@@ -1326,7 +1237,6 @@ describe('InstanceAiService — background task auto-follow-up', () => {
workItemId: 'wi-1',
run: async () => 'done',
},
{},
'group-1',
);
await getSpawnOptions().onSettled?.(task);
@@ -1349,20 +1259,12 @@ describe('InstanceAiService — background task auto-follow-up', () => {
role: 'workflow-builder',
run: async () => 'done',
},
{},
'group-1',
);
await getSpawnOptions().onSettled?.(task);
expect(service.startInternalFollowUpRun).not.toHaveBeenCalled();
expect(service.terminalOutcome.recordBackgroundTerminalOutcome).toHaveBeenCalledWith(task);
expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith(
'thread-a',
'run-1',
{},
true,
'group-1',
);
});
it('skips internal follow-up when the task itself timed out', async () => {
@@ -1380,7 +1282,6 @@ describe('InstanceAiService — background task auto-follow-up', () => {
role: 'workflow-builder',
run: async () => 'done',
},
{},
'group-1',
);
await getSpawnOptions().onSettled?.(task);
@@ -1934,7 +1835,6 @@ type SuspendedRunResumeServiceInternals = {
};
emitTerminalRun: Mock;
logger: { warn: Mock; debug: Mock };
dbSnapshotStorage: unknown;
tracing: { createOrchestratorResumeTraceContext: Mock; finalizeDetachedTraceRun: Mock };
memoryService: { getThreadMetadata: Mock };
processResumedStream: Mock;
@@ -1978,7 +1878,6 @@ function createSuspendedRunResumeService(): SuspendedRunResumeServiceInternals {
service.emitTerminalRun = vi.fn(async () => {});
service.logger = { warn: vi.fn(), debug: vi.fn() };
service.memoryService = { getThreadMetadata: vi.fn(async () => undefined) };
service.dbSnapshotStorage = {};
service.tracing = {
createOrchestratorResumeTraceContext: vi.fn(async () => undefined),
finalizeDetachedTraceRun: vi.fn(async () => {}),
@@ -2951,139 +2850,6 @@ describe('InstanceAiService — rebuildAgentForResume', () => {
});
});
describe('InstanceAiService — agent tree snapshots', () => {
beforeEach(() => {
(buildAgentTreeFromEvents as Mock).mockImplementation(
(events: Array<{ type: string; payload?: { text?: string } }>) => ({
agentId: 'agent-001',
role: 'orchestrator',
status: 'completed',
textContent: events
.map((event) => (event.type === 'text-delta' ? (event.payload?.text ?? '') : ''))
.join(''),
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
}),
);
});
it('falls back to persisted run ids when an old background group mapping was pruned', async () => {
const service = createSnapshotService();
const terminalEvent: InstanceAiEvent = {
type: 'text-delta',
runId: 'run-background',
agentId: 'agent-001',
payload: { text: 'background finished' },
};
const snapshotStorage = {
getLatest: vi.fn(async () => ({
tree: makeAgentTree(),
runId: 'run-original',
messageGroupId: 'group-old',
runIds: ['run-original', 'run-background'],
})),
save: vi.fn(async () => {}),
updateLast: vi.fn(async () => {}),
};
service.eventLog.getEventsForRuns.mockResolvedValue([terminalEvent]);
await service.saveAgentTreeSnapshot(
'thread-a',
'run-background',
snapshotStorage,
true,
'group-old',
);
expect(service.runState.getRunIdsForMessageGroup).toHaveBeenCalledWith('group-old');
expect(snapshotStorage.getLatest).toHaveBeenCalledWith('thread-a', {
messageGroupId: 'group-old',
runId: 'run-background',
});
expect(service.eventLog.getEventsForRuns).toHaveBeenCalledWith('thread-a', [
'run-original',
'run-background',
]);
expect(snapshotStorage.updateLast).toHaveBeenCalledWith(
'thread-a',
expect.objectContaining({ textContent: 'background finished' }),
'run-background',
expect.objectContaining({
messageGroupId: 'group-old',
runIds: ['run-original', 'run-background'],
}),
);
expect(snapshotStorage.save).not.toHaveBeenCalled();
});
it('skips update snapshots when no events are available for a pruned group', async () => {
const service = createSnapshotService();
const snapshotStorage = {
getLatest: vi.fn(async () => ({
tree: makeAgentTree(),
runId: 'run-original',
messageGroupId: 'group-old',
runIds: ['run-background'],
})),
save: vi.fn(async () => {}),
updateLast: vi.fn(async () => {}),
};
await service.saveAgentTreeSnapshot(
'thread-a',
'run-background',
snapshotStorage,
true,
'group-old',
);
expect(snapshotStorage.updateLast).not.toHaveBeenCalled();
expect(snapshotStorage.save).not.toHaveBeenCalled();
expect(service.logger.warn).toHaveBeenCalledWith(
'Skipped updating empty Instance AI agent tree snapshot',
expect.objectContaining({
threadId: 'thread-a',
runId: 'run-background',
messageGroupId: 'group-old',
}),
);
});
it('reads snapshot input from the durable log', async () => {
const service = createSnapshotService();
const logEvent: InstanceAiEvent = {
type: 'text-delta',
runId: 'run-1',
agentId: 'agent-001',
payload: { text: 'from the log' },
};
service.eventLog.getEventsForRuns.mockResolvedValue([logEvent]);
const snapshotStorage = {
getLatest: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
updateLast: vi.fn(async () => {}),
};
await service.saveAgentTreeSnapshot('thread-a', 'run-1', snapshotStorage);
expect(service.eventLog.getEventsForRuns).toHaveBeenCalledWith('thread-a', ['run-1']);
// Read-own-writes barrier: the drain settles before the snapshot input is
// read, so a just-published terminal fact can't be missing from the tree.
expect(service.eventLog.flush).toHaveBeenCalledWith('thread-a');
expect(service.eventLog.flush.mock.invocationCallOrder[0]).toBeLessThan(
service.eventLog.getEventsForRuns.mock.invocationCallOrder[0],
);
expect(snapshotStorage.save).toHaveBeenCalledWith(
'thread-a',
expect.objectContaining({ textContent: 'from the log' }),
'run-1',
expect.any(Object),
);
});
});
describe('InstanceAiService — terminal response guard wiring', () => {
beforeEach(() => {
vi.mocked(resumeAgentRun).mockReset();
@@ -3109,12 +2875,10 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
},
);
expect(service.eventBus.events.map((event) => event.type)).toEqual(['error', 'run-finish']);
expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
// Thrown run-loop errors must reach telemetry too, not just the SSE stream
expect(service.telemetry.track).toHaveBeenCalledWith('instance_ai_run_finished', {
thread_id: 'thread-a',
@@ -3150,7 +2914,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
resumeExecutionToken,
},
);
@@ -3184,7 +2947,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
resumeExecutionToken,
},
);
@@ -3216,7 +2978,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
},
);
@@ -3253,7 +3014,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
},
);
@@ -3286,7 +3046,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
},
);
@@ -3356,7 +3115,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
},
);
@@ -3443,7 +3201,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
},
);
@@ -3485,7 +3242,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
},
);
@@ -3553,7 +3309,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
},
);
@@ -3603,7 +3358,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
tracing,
},
);
@@ -3647,7 +3401,7 @@ describe('InstanceAiService — terminal response guard wiring', () => {
const segmentBTracing = {
actorRun: { id: 'segment-b-actor' },
} as unknown as InstanceAiTraceContext;
service.saveAgentTreeSnapshot.mockImplementationOnce(async () => {
service.tracing.finalizeRunTracing.mockImplementationOnce(async () => {
// The next approval can register its trace immediately after the
// confirmation is published, before this segment reaches finally.
service.tracing.registerTraceContext('run-1', 'thread-a', segmentBTracing, 'group-1');
@@ -3676,7 +3430,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
tracing: segmentATracing,
},
);
@@ -3722,7 +3475,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
};
// Segment A: the resumed run suspends again on HITL.
@@ -3808,7 +3560,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
};
// Segment A: the resumed run suspends again on HITL.
@@ -3895,7 +3646,6 @@ describe('InstanceAiService — terminal response guard wiring', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
tracing,
},
);
@@ -4070,7 +3820,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
toolCallId: 'tool-call-1',
signal: abortController.signal,
abortController,
snapshotStorage: {},
resumeExecutionToken: Symbol('resume-execution'),
});
@@ -4150,7 +3899,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
expect(service.eventBus.events).toEqual([]);
expect(service.tracing.finalizeRunTracing).not.toHaveBeenCalled();
expect(service.tracing.maybeFinalizeRunTraceRoot).not.toHaveBeenCalled();
expect(service.saveAgentTreeSnapshot).not.toHaveBeenCalled();
expect(service.temporaryWorkflowService.reapForRun).not.toHaveBeenCalled();
expect(service.finalizeRun).not.toHaveBeenCalled();
expect(service.creditService.claimRunUsage).not.toHaveBeenCalled();
@@ -4219,7 +3967,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
errorCode: undefined,
});
expect(service.eventBus.events.map((event) => event.type)).toEqual(['error', 'run-finish']);
expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
expect(service.tracing.finalizeRunTracing).not.toHaveBeenCalled();
expect(service.tracing.finalizeMessageTraceRoot).not.toHaveBeenCalled();
expect(service.schedulePlannedTasks).not.toHaveBeenCalled();
@@ -4260,7 +4007,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
);
expect(terminalResponse).not.toHaveBeenCalled();
expect(service.eventBus.events).toEqual([]);
expect(service.saveAgentTreeSnapshot).not.toHaveBeenCalled();
});
it('surfaces an error to the user when a resume throws before claiming its checkpoint', async () => {
@@ -4310,7 +4056,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
error_source: 'exception',
user_id: 'user-1',
});
expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
expect(service.runState.clearActiveRun).toHaveBeenCalledWith(
'thread-a',
opts.resumeExecutionToken,
@@ -4329,12 +4074,11 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
expect(service.eventBus.events.map((event) => event.type)).toEqual(['error', 'run-finish']);
});
it('still finishes a failed resume when the guard read and the snapshot save fail', async () => {
it('still finishes a failed resume when the guard read fails', async () => {
const service = createTerminalGuardOrderService();
const abortController = new AbortController();
const opts = { ...resumedStreamOpts(abortController), messageGroupId: 'group-1' };
service.eventBus.getEventsForRuns.mockRejectedValue(new Error('event log unavailable'));
service.saveAgentTreeSnapshot.mockRejectedValue(new Error('snapshot write failed'));
vi.mocked(resumeAgentRun).mockRejectedValueOnce(new Error('Invalid resume payload'));
await service.processResumedStream({}, {}, opts);
@@ -4344,10 +4088,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
'Failed to evaluate the terminal response for a settling run',
expect.objectContaining({ error: 'event log unavailable' }),
);
expect(service.logger.warn).toHaveBeenCalledWith(
'Failed to save the agent tree snapshot for a settling run',
expect.objectContaining({ error: 'snapshot write failed' }),
);
});
it('cancels the run when a resume is aborted before claiming its checkpoint', async () => {
@@ -4383,7 +4123,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
},
}),
]);
expect(service.saveAgentTreeSnapshot).toHaveBeenCalledWith('thread-a', 'run-1', {});
});
it('reports the run timeout reason when a resume times out before claiming', async () => {
@@ -4404,7 +4143,7 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
]);
});
it('leaves a preserved HITL snapshot alone when a resume is aborted before claiming', async () => {
it('leaves a preserved HITL run alone when a resume is aborted before claiming', async () => {
const service = createTerminalGuardOrderService();
const abortController = new AbortController();
const opts = { ...resumedStreamOpts(abortController), messageGroupId: 'group-1' };
@@ -4415,7 +4154,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
await service.processResumedStream({}, {}, opts);
expect(service.eventBus.events).toEqual([]);
expect(service.saveAgentTreeSnapshot).not.toHaveBeenCalled();
});
it('terminalizes a same-name error that occurs after the resume was claimed', async () => {
@@ -4442,7 +4180,6 @@ describe('InstanceAiService — run error reporter lifecycle', () => {
'thread-a',
'run-1',
'errored',
{},
expect.any(Object),
);
expect(service.instanceAiErrorReporter.endRun).toHaveBeenCalledWith(
@@ -4732,7 +4469,6 @@ describe('InstanceAiService — planned task settlement', () => {
syncPlannedTasksToUi: Mock;
eventBus: { publish: Mock };
createPlannedTaskState: Mock;
saveAgentTreeSnapshot: Mock;
cancelAwaitingApprovalPlan: Mock;
backgroundTasks: {
cancelThread: Mock;
@@ -4766,7 +4502,6 @@ describe('InstanceAiService — planned task settlement', () => {
createPlannedTaskState: vi.fn(async () => ({ plannedTaskService })),
syncPlannedTasksToUi: vi.fn(async () => {}),
schedulePlannedTasks: vi.fn(async () => {}),
saveAgentTreeSnapshot: vi.fn(async () => {}),
cancelAwaitingApprovalPlan: vi.fn(async () => {}),
backgroundTasks: {
cancelThread: vi.fn(() => [task]),
@@ -92,156 +92,8 @@ describe('parseStoredMessages', () => {
expect(assistant.content).toBe('Hello! How can I help?');
expect(assistant.reasoning).toBe('');
expect(assistant.isStreaming).toBe(false);
expect(assistant.agentTree).toBeDefined();
expect(assistant.agentTree?.textContent).toBe('Hello! How can I help?');
});
it('should parse assistant message with tool invocations (result state)', () => {
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'List workflows',
createdAt: makeDate(),
},
{
id: 'msg-a',
role: 'assistant',
content: [
{ type: 'text', text: 'Here are your workflows' },
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'list-workflows',
input: { limit: 10 },
state: 'resolved',
output: { workflows: ['wf1'] },
},
],
createdAt: makeDate(1),
},
];
const result = parseStoredMessages(messages);
const assistant = result[1];
expect(assistant.agentTree?.toolCalls).toHaveLength(1);
expect(assistant.agentTree?.toolCalls[0]).toMatchObject({
toolCallId: 'tc-1',
toolName: 'list-workflows',
args: { limit: 10 },
result: { workflows: ['wf1'] },
isLoading: false,
renderHint: 'default',
});
});
it('should parse assistant message with tool invocations (call state - interrupted)', () => {
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'Do something',
createdAt: makeDate(),
},
{
id: 'msg-a',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-2',
toolName: 'task-control',
input: { tasks: [] },
},
],
createdAt: makeDate(1),
},
];
const result = parseStoredMessages(messages);
const tc = result[1].agentTree?.toolCalls[0];
expect(tc?.isLoading).toBe(true);
expect(tc?.result).toBeUndefined();
expect(tc?.renderHint).toBe('tasks');
});
it('should surface rejected tool calls via `error`, not `result`', () => {
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'Do something',
createdAt: makeDate(),
},
{
id: 'msg-a',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-rej',
toolName: 'workflows',
input: { name: 'x' },
state: 'rejected',
error: 'Workflow not found',
},
],
createdAt: makeDate(1),
},
];
const result = parseStoredMessages(messages);
const tc = result[1].agentTree?.toolCalls[0];
expect(tc?.isLoading).toBe(false);
expect(tc?.result).toBeUndefined();
expect(tc?.error).toBe('Workflow not found');
});
it('should skip malformed tool-call parts instead of rendering half-populated cards', () => {
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'Go',
createdAt: makeDate(),
},
{
id: 'msg-a',
role: 'assistant',
content: [
// Valid tool call — should survive.
{
type: 'tool-call',
toolCallId: 'tc-ok',
toolName: 'list-workflows',
input: {},
state: 'resolved',
output: { ok: true },
},
// Missing toolName — fails the schema, must be dropped.
{ type: 'tool-call', toolCallId: 'tc-no-name', input: {}, state: 'resolved' },
// Missing toolCallId — dropped.
{ type: 'tool-call', toolName: 'orphan', input: {}, state: 'resolved' },
// `error` wrong type for a rejected call — dropped.
{
type: 'tool-call',
toolCallId: 'tc-bad-error',
toolName: 'workflows',
state: 'rejected',
error: { not: 'a string' },
},
],
createdAt: makeDate(1),
},
];
const result = parseStoredMessages(messages);
const toolCalls = result[1].agentTree?.toolCalls ?? [];
expect(toolCalls.map((tc) => tc.toolCallId)).toEqual(['tc-ok']);
// No snapshot → the row renders from its own content, without a tree.
expect(assistant.agentTree).toBeUndefined();
});
it('should drop content parts with an unrecognized type', () => {
@@ -267,9 +119,7 @@ describe('parseStoredMessages', () => {
const result = parseStoredMessages(messages);
expect(result[1].content).toBe('Hello');
expect(result[1].agentTree?.timeline).toEqual([
{ type: 'text', content: 'Hello', responseId: 'msg-a' },
]);
expect(result[1].agentTree).toBeUndefined();
});
it('should parse reasoning from native parts', () => {
@@ -295,14 +145,10 @@ describe('parseStoredMessages', () => {
expect(result[1].reasoning).toBe('Reasoning part');
expect(result[1].content).toBe('Answer');
// Reasoning keeps its chronological slot in the timeline
expect(result[1].agentTree?.timeline).toEqual([
{ type: 'reasoning', content: 'Reasoning part', responseId: 'msg-a' },
{ type: 'text', content: 'Answer', responseId: 'msg-a' },
]);
expect(result[1].agentTree).toBeUndefined();
});
it('should build agentTree for reasoning-only assistant messages', () => {
it('should parse reasoning-only assistant messages without a tree', () => {
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
@@ -322,104 +168,7 @@ describe('parseStoredMessages', () => {
expect(result[1].reasoning).toBe('Just reasoning');
expect(result[1].content).toBe('');
expect(result[1].agentTree?.timeline).toEqual([
{ type: 'reasoning', content: 'Just reasoning', responseId: 'msg-a' },
]);
});
it('should bracket reconstructed tool calls with adjacent message timestamps', () => {
// Snapshot-less reloads have no real per-call timestamps; the interval
// between stored rows approximates them so thinking blocks can still
// derive a "Thought for Xs" duration.
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'Build it',
createdAt: makeDate(),
},
{
id: 'msg-a1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-done',
toolName: 'search-nodes',
input: {},
state: 'resolved',
output: {},
},
{
type: 'tool-call',
toolCallId: 'tc-pending',
toolName: 'build-workflow',
input: {},
state: 'pending',
},
],
createdAt: makeDate(12_000),
},
];
const result = parseStoredMessages(messages);
const toolCalls = result[1].agentTree?.toolCalls ?? [];
expect(toolCalls[0]).toMatchObject({
toolCallId: 'tc-done',
startedAt: makeDate().toISOString(),
completedAt: makeDate(12_000).toISOString(),
});
// An unresolved call gets no completedAt — it never finished.
expect(toolCalls[1].startedAt).toBe(makeDate().toISOString());
expect(toolCalls[1].completedAt).toBeUndefined();
});
it('should group each assistant row under its own synthetic responseId', () => {
// One stored assistant row = one LLM response. The synthetic per-row
// responseId lets the frontend fold narration (text followed by trace
// content in the same response) into thinking blocks after a reload
// where no snapshot survived.
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'Build it',
createdAt: makeDate(),
},
{
id: 'msg-a1',
role: 'assistant',
content: [
{ type: 'text', text: 'Checking the nodes first.' },
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'search-nodes',
input: {},
state: 'resolved',
output: {},
},
],
createdAt: makeDate(1),
},
{
id: 'msg-a2',
role: 'assistant',
content: [{ type: 'text', text: 'Done — here is the workflow.' }],
createdAt: makeDate(2),
},
];
const result = parseStoredMessages(messages);
expect(result[1].agentTree?.timeline).toEqual([
{ type: 'text', content: 'Checking the nodes first.', responseId: 'msg-a1' },
{ type: 'tool-call', toolCallId: 'tc-1', responseId: 'msg-a1' },
]);
expect(result[2].agentTree?.timeline).toEqual([
{ type: 'text', content: 'Done — here is the workflow.', responseId: 'msg-a2' },
]);
expect(result[1].agentTree).toBeUndefined();
});
it('should normalize legacy aggregate reasoning into the timeline on reload', () => {
@@ -519,64 +268,6 @@ describe('parseStoredMessages', () => {
expect(result[1].runId).toBe('run_abc123');
});
it('should render the message-derived tree when the paired snapshot is an empty cancelled tree', () => {
const messages: StoredAgentMessage[] = [
{ id: 'msg-u', role: 'user', content: 'Build something', createdAt: makeDate() },
{
id: 'msg-a',
role: 'assistant',
content: [
{ type: 'text', text: 'Working on it' },
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'gmail',
input: { q: 'invoices' },
state: 'resolved',
output: { count: 3 },
},
],
createdAt: makeDate(1),
},
];
// Snapshot saved at cancel time (after the message): an empty `cancelled` tree
// from a run whose events were lost before the snapshot was built.
const cancelledTree: InstanceAiAgentNode = {
agentId: 'agent-001',
role: 'orchestrator',
status: 'cancelled',
cancellationReason: 'user',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
};
const snapshots = [
{
tree: cancelledTree,
runId: 'run_x',
messageGroupId: 'mg_x',
createdAt: makeDate(5),
updatedAt: makeDate(5),
},
];
const result = parseStoredMessages(messages, snapshots);
const assistant = result.find((m) => m.role === 'assistant');
// The empty snapshot is discarded in favour of the message content.
expect(assistant?.agentTree?.toolCalls).toHaveLength(1);
expect(assistant?.agentTree?.toolCalls[0].toolName).toBe('gmail');
expect(assistant?.agentTree?.textContent).toBe('Working on it');
// The cancelled status + cause are inherited from the snapshot so the UI can flag it.
expect(assistant?.agentTree?.status).toBe('cancelled');
expect(assistant?.agentTree?.cancellationReason).toBe('user');
// Snapshot grouping metadata is still attached.
expect(assistant?.messageGroupId).toBe('mg_x');
expect(assistant?.runId).toBe('run_x');
});
it('should keep a task-list-only snapshot instead of discarding it as empty', () => {
const messages: StoredAgentMessage[] = [
{ id: 'msg-u', role: 'user', content: 'plan it', createdAt: makeDate() },
@@ -617,267 +308,14 @@ describe('parseStoredMessages', () => {
expect(assistant?.agentTree?.tasks?.tasks).toHaveLength(1);
});
it('should normalize a non-terminal (running) snapshot status to completed', () => {
const messages: StoredAgentMessage[] = [
{ id: 'msg-u', role: 'user', content: 'do it', createdAt: makeDate() },
{
id: 'msg-a',
role: 'assistant',
content: [{ type: 'text', text: 'Partial work' }],
createdAt: makeDate(1),
},
];
// A mid-run snapshot (e.g. persisted while active) that is otherwise empty. A
// reconstructed historical turn has no live stream, so it must not stay "busy".
const runningTree: InstanceAiAgentNode = {
agentId: 'agent-001',
role: 'orchestrator',
status: 'active',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
};
const snapshots = [
{
tree: runningTree,
runId: 'run_x',
messageGroupId: 'mg_x',
createdAt: makeDate(2),
updatedAt: makeDate(2),
},
];
const result = parseStoredMessages(messages, snapshots);
const assistant = result.find((m) => m.role === 'assistant');
expect(assistant?.agentTree?.status).toBe('completed');
});
it('should settle a pending tool call when reconstructing a cancelled turn', () => {
const messages: StoredAgentMessage[] = [
{ id: 'msg-u', role: 'user', content: 'run tools', createdAt: makeDate() },
{
id: 'msg-a',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'gmail',
input: {},
state: 'pending',
},
],
createdAt: makeDate(1),
},
];
const cancelledTree: InstanceAiAgentNode = {
agentId: 'agent-001',
role: 'orchestrator',
status: 'cancelled',
cancellationReason: 'user',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
};
const snapshots = [
{
tree: cancelledTree,
runId: 'run_x',
messageGroupId: 'mg_x',
createdAt: makeDate(2),
updatedAt: makeDate(2),
},
];
const result = parseStoredMessages(messages, snapshots);
const assistant = result.find((m) => m.role === 'assistant');
expect(assistant?.agentTree?.status).toBe('cancelled');
const toolCall = assistant?.agentTree?.toolCalls.find((t) => t.toolCallId === 'tc-1');
// No spinner that never resolves next to "You stopped this run".
expect(toolCall?.isLoading).toBe(false);
});
it('should aggregate a multi-row cancelled turn into one bubble, not just the last row', () => {
const messages: StoredAgentMessage[] = [
{ id: 'msg-u', role: 'user', content: 'Build something', createdAt: makeDate() },
{
id: 'msg-a1',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'gmail',
input: {},
state: 'resolved',
output: {},
},
],
createdAt: makeDate(1),
},
{
id: 'msg-a2',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-2',
toolName: 'sheets',
input: {},
state: 'resolved',
output: {},
},
],
createdAt: makeDate(2),
},
];
const cancelledTree: InstanceAiAgentNode = {
agentId: 'agent-001',
role: 'orchestrator',
status: 'cancelled',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
};
const snapshots = [
{
tree: cancelledTree,
runId: 'run_x',
messageGroupId: 'mg_x',
createdAt: makeDate(5),
updatedAt: makeDate(5),
},
];
const result = parseStoredMessages(messages, snapshots);
const assistants = result.filter((m) => m.role === 'assistant');
expect(assistants).toHaveLength(1);
// Both iterations' tool calls survive the dedup collapse, in chronological order.
expect(assistants[0].agentTree?.toolCalls.map((t) => t.toolName)).toEqual([
'gmail',
'sheets',
]);
});
it('should reconstruct a completed run whose snapshot was lost (real-world: bus eviction)', () => {
// Reproduces thread 058e9ce5: a long HITL build run whose snapshot was rebuilt
// from an already-evicted event bus, persisting an empty `agent-001` tree even
// though the run completed. The orchestrator's work survives in the message rows,
// so the parser must reconstruct it. Real timestamps — the snapshot lands between
// rows a3 and a4, so it pairs with a3 (pairing is timestamp-sensitive).
const at = (iso: string): Date => new Date(iso);
const tc = (toolCallId: string, toolName: string) => ({
type: 'tool-call' as const,
toolCallId,
toolName,
input: {},
state: 'resolved' as const,
output: {},
});
const messages: StoredAgentMessage[] = [
{
id: 'u',
role: 'user',
content: 'get my linear issues via http request',
createdAt: at('2026-06-30T10:12:11.929Z'),
},
{
id: 'a1',
role: 'assistant',
content: [tc('t1', 'load_skill'), tc('t2', 'credentials')],
createdAt: at('2026-06-30T10:12:16.626Z'),
},
{
id: 'a2',
role: 'assistant',
content: [tc('t3', 'nodes'), tc('t4', 'credentials')],
createdAt: at('2026-06-30T10:12:23.292Z'),
},
{
id: 'a3',
role: 'assistant',
content: [tc('t5', 'research')],
createdAt: at('2026-06-30T10:13:07.951Z'),
},
{
id: 'a4',
role: 'assistant',
content: [tc('t6', 'workspace_write_file')],
createdAt: at('2026-06-30T10:13:30.262Z'),
},
{
id: 'a5',
role: 'assistant',
content: [tc('t7', 'build-workflow')],
createdAt: at('2026-06-30T10:14:05.669Z'),
},
{
id: 'a6',
role: 'assistant',
content: [tc('t8', 'verify-built-workflow')],
createdAt: at('2026-06-30T10:14:21.413Z'),
},
{
id: 'a7',
role: 'assistant',
content: [{ type: 'text', text: 'The workflow is ready to test.' }],
createdAt: at('2026-06-30T10:52:59.237Z'),
},
];
const emptyTree: InstanceAiAgentNode = {
agentId: 'agent-001',
role: 'orchestrator',
status: 'completed',
textContent: '',
reasoning: '',
toolCalls: [],
children: [],
timeline: [],
};
const snapshots = [
{
tree: emptyTree,
runId: 'run_YNR',
messageGroupId: 'mg_N-C2',
runIds: ['run_YNR'],
createdAt: at('2026-06-30T10:13:08.274Z'),
updatedAt: at('2026-06-30T10:13:08.274Z'),
},
];
const result = parseStoredMessages(messages, snapshots);
// One bubble with the full orchestrator activity, not an empty `cancelled`-looking card.
const assistants = result.filter((m) => m.role === 'assistant');
expect(assistants).toHaveLength(1);
expect(assistants[0].agentTree?.toolCalls.map((t) => t.toolName)).toEqual([
'load_skill',
'credentials',
'nodes',
'credentials',
'research',
'workspace_write_file',
'build-workflow',
'verify-built-workflow',
]);
});
it('should reconstruct tool calls when an empty snapshot is consumed as a leading orphan', () => {
it('should collapse an empty leading-orphan snapshot into the turn instead of rendering an empty card', () => {
// Real-world (thread 36a79497 / fal.ai build): a completed run whose snapshot was
// rebuilt from an already-evicted event bus, persisting an empty `agent-001` tree
// tagged with the turn's messageGroupId. Its createdAt sits at run start — before
// every assistant row — so it is consumed as a chronological *orphan* rather than
// paired. The empty orphan must not clobber the message-derived flat tree when the
// dedup pass collapses the turn.
// paired. The empty orphan must not surface as an assistant card of its own:
// group-id propagation folds it into the turn and the kept row renders from its
// own content, without the empty tree leaking through.
const tc = (toolCallId: string, toolName: string) => ({
type: 'tool-call' as const,
toolCallId,
@@ -928,20 +366,16 @@ describe('parseStoredMessages', () => {
const assistants = result.filter((m) => m.role === 'assistant');
expect(assistants).toHaveLength(1);
// The empty orphan snapshot must not overwrite the reconstructed activity.
expect(assistants[0].agentTree?.toolCalls.map((t) => t.toolName)).toEqual([
'workspace_write_file',
'workflows',
]);
expect(assistants[0].agentTree?.textContent).toBe('The workflow is built and verified.');
// The non-renderable orphan tree is never authoritative.
expect(assistants[0].agentTree).toBeUndefined();
expect(assistants[0].content).toBe('The workflow is built and verified.');
expect(assistants[0].messageGroupId).toBe('mg_1');
});
it('should not surface an empty paired snapshot tree on a content-less assistant row', () => {
// Same invariant as the orphan guard, on the *pairing* path: a non-renderable
// snapshot must never be authoritative. Here the empty snapshot pairs with an
// assistant row that also has no text/tools, so there is no flat tree to fall
// back to either — the row must end up with no tree, not the empty `agent-001`
// snapshot tree leaking through.
// snapshot must never be authoritative — the row must end up with no tree, not
// the empty `agent-001` snapshot tree leaking through.
const messages: StoredAgentMessage[] = [
{ id: 'u', role: 'user', content: 'do nothing', createdAt: makeDate(0) },
{ id: 'a', role: 'assistant', content: [], createdAt: makeDate(1) },
@@ -1119,15 +553,12 @@ describe('parseStoredMessages', () => {
});
});
it('should keep the snapshot tree when dedupe collapses in-flight checkpoint messages', () => {
// Simulates the in-flight HITL case: the SDK hasn't committed
// the turn to memory yet, so `loadInFlightCheckpointMessages`
// surfaces several intermediate assistant messages from the
// checkpoint blob. The snapshot was paired with a middle
// message via timestamp matching, while a later message
// (with no tree of its own) carries the latest text. Dedupe
// must transfer the agentTree forward so the confirmation
// card in the snapshot tree survives.
it('should keep the snapshot tree when dedupe collapses intra-turn assistant rows', () => {
// Simulates the in-flight HITL case: the turn has produced several
// assistant rows, and the snapshot was paired with a middle one via
// timestamp matching, while a later row (with no tree of its own)
// carries the latest text. Dedupe must transfer the agentTree
// forward so the confirmation card in the snapshot tree survives.
const snapshotTree: InstanceAiAgentNode = {
agentId: 'agent-001',
role: 'orchestrator',
@@ -1199,55 +630,6 @@ describe('parseStoredMessages', () => {
expect(assistant.agentTree).toBe(snapshotTree);
expect(assistant.agentTree?.toolCalls[0].confirmation?.requestId).toBe('req-live');
});
it('should apply renderHint correctly for known tool names', () => {
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'Go',
createdAt: makeDate(),
},
{
id: 'msg-a',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-1',
toolName: 'delegate',
input: {},
state: 'resolved',
output: 'ok',
},
{
type: 'tool-call',
toolCallId: 'tc-2',
toolName: 'build-workflow',
input: {},
state: 'resolved',
output: 'ok',
},
{
type: 'tool-call',
toolCallId: 'tc-3',
toolName: 'create-tasks',
input: {},
state: 'resolved',
output: 'ok',
},
],
createdAt: makeDate(1),
},
];
const result = parseStoredMessages(messages);
const toolCalls = result[1].agentTree?.toolCalls ?? [];
expect(toolCalls[0].renderHint).toBe('default');
expect(toolCalls[1].renderHint).toBe('builder');
expect(toolCalls[2].renderHint).toBe('planner');
});
});
describe('internal enrichment stripping', () => {
@@ -1587,40 +969,9 @@ describe('parseStoredMessages', () => {
expect(result).toHaveLength(1);
expect(result[0].content).toBe('');
// No tool calls and no text → no agentTree
// No snapshot → no agentTree
expect(result[0].agentTree).toBeUndefined();
});
it('should extract tool calls from native parts', () => {
const messages: StoredAgentMessage[] = [
{
id: 'msg-u',
role: 'user',
content: 'test',
createdAt: makeDate(),
},
{
id: 'msg-a',
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'tc-parts',
toolName: 'create-tasks',
input: { goal: 'x' },
state: 'resolved',
output: 'done',
},
],
createdAt: makeDate(1),
},
];
const result = parseStoredMessages(messages);
expect(result[1].agentTree?.toolCalls).toHaveLength(1);
expect(result[1].agentTree?.toolCalls[0].toolCallId).toBe('tc-parts');
});
});
});
@@ -10,7 +10,6 @@ import {
type OrphanConfirmationStore,
type RebuildSuspendedRunOutcome,
type RunFinishEventPublisher,
type RunSnapshotCanceller,
type SuspendedRunRebuilder,
type SuspendedRunStateRegistry,
} from '../suspended-run-restorer.service';
@@ -19,7 +18,6 @@ type Mocks = {
logger: MockProxy<Logger>;
pendingConfirmationRepo: MockProxy<OrphanConfirmationStore>;
runState: MockProxy<SuspendedRunStateRegistry>;
dbSnapshotStorage: MockProxy<RunSnapshotCanceller>;
eventBus: MockProxy<RunFinishEventPublisher>;
rebuilder: MockProxy<SuspendedRunRebuilder>;
};
@@ -29,11 +27,9 @@ function createRestorer(): { restorer: SuspendedRunRestorer; mocks: Mocks } {
logger: mock<Logger>(),
pendingConfirmationRepo: mock<OrphanConfirmationStore>(),
runState: mock<SuspendedRunStateRegistry>(),
dbSnapshotStorage: mock<RunSnapshotCanceller>(),
eventBus: mock<RunFinishEventPublisher>(),
rebuilder: mock<SuspendedRunRebuilder>(),
};
mocks.dbSnapshotStorage.markRunCancelled.mockResolvedValue();
mocks.rebuilder.resumeSuspendedRun.mockResolvedValue(null);
const restorer = new SuspendedRunRestorer(mocks);
@@ -99,7 +95,6 @@ describe('SuspendedRunRestorer — orphan restoration', () => {
}),
}),
);
expect(mocks.dbSnapshotStorage.markRunCancelled).toHaveBeenCalledWith('thread-1', 'run-1');
});
it('throws when a suspended orphan lacks the pointers needed to resume', async () => {
@@ -174,7 +169,6 @@ describe('SuspendedRunRestorer — orphan restoration', () => {
expect(mocks.runState.suspendRun).not.toHaveBeenCalled();
expect(mocks.rebuilder.resumeSuspendedRun).not.toHaveBeenCalled();
expect(mocks.eventBus.publish).not.toHaveBeenCalled();
expect(mocks.dbSnapshotStorage.markRunCancelled).not.toHaveBeenCalled();
});
it('rejects a stale inline-kind row without cancelling anything when the thread has a live run', async () => {
@@ -194,7 +188,6 @@ describe('SuspendedRunRestorer — orphan restoration', () => {
expect(result).toBeNull();
expect(mocks.eventBus.publish).not.toHaveBeenCalled();
expect(mocks.dbSnapshotStorage.markRunCancelled).not.toHaveBeenCalled();
});
it('falls back to the terminal UserError when the rebuild fails', async () => {
@@ -1,7 +1,6 @@
export { InstanceAiThread } from './instance-ai-thread.entity';
export { InstanceAiMessage } from './instance-ai-message.entity';
export { InstanceAiResource } from './instance-ai-resource.entity';
export { InstanceAiRunSnapshot } from './instance-ai-run-snapshot.entity';
export { InstanceAiIterationLog } from './instance-ai-iteration-log.entity';
export { InstanceAiCheckpoint } from './instance-ai-checkpoint.entity';
export { InstanceAiObservation } from './instance-ai-observation.entity';
@@ -1,34 +0,0 @@
import { WithTimestamps } from '@n8n/db';
import { Column, Entity, Index, PrimaryColumn } from '@n8n/typeorm';
@Entity({ name: 'instance_ai_run_snapshots' })
@Index(['threadId', 'messageGroupId'])
@Index(['threadId', 'createdAt'])
export class InstanceAiRunSnapshot extends WithTimestamps {
@PrimaryColumn('uuid')
threadId: string;
@PrimaryColumn({ type: 'varchar', length: 36 })
runId: string;
@Column({ type: 'varchar', length: 36, nullable: true })
messageGroupId: string | null;
@Column({ type: 'simple-json', nullable: true })
runIds: string[] | null;
@Column({ type: 'text' })
tree: string;
@Column({ type: 'varchar', length: 64, nullable: true })
traceId: string | null;
@Column({ type: 'varchar', length: 64, nullable: true })
spanId: string | null;
@Column({ type: 'varchar', length: 36, nullable: true })
langsmithRunId: string | null;
@Column({ type: 'varchar', length: 36, nullable: true })
langsmithTraceId: string | null;
}
@@ -103,15 +103,6 @@ export class DurableLogMetrics {
this.eventService.emit('instance-ai-history-folded', { latencyMs, trees });
}
/**
* The parser is pure module code and keeps its own counter
* (messageParserStats); this only forwards new activations to the
* metrics pipeline.
*/
notifyParserFallbacks(count: number): void {
if (count > 0) this.eventService.emit('instance-ai-parser-fallback', { count });
}
recordSweepRunExamined(): void {
this.sweep.runsExamined++;
}
@@ -42,7 +42,7 @@ export class InProcessEventBus implements InstanceAiEventBus {
* facts.
*/
publish(threadId: string, event: InstanceAiEvent): void {
// Stamp publish time once — replays (SSE reconnect, snapshot rebuilds)
// Stamp publish time once — replays (SSE reconnect, history folds)
// rely on it to reconstruct real timing instead of processing time, and
// persisted events must carry it too.
if (event.ts === undefined) {
@@ -20,8 +20,6 @@ import {
type AgentTreeSnapshot,
} from '@n8n/instance-ai';
import { DbSnapshotStorage } from './storage/db-snapshot-storage';
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
import { NotFoundError } from '@/errors/response-errors/not-found.error';
@@ -30,7 +28,6 @@ import { DurableLogMetrics } from './event-bus/durable-log-metrics';
import {
collectConfirmationRequestIds,
markExpiredConfirmations,
messageParserStats,
parseStoredMessages,
} from './message-parser';
import { InstanceAiCheckpointRepository } from './repositories/instance-ai-checkpoint.repository';
@@ -45,15 +42,6 @@ export interface InstanceAiThreadLaunchMetadata {
sourceContext?: Record<string, unknown>;
}
function isAgentMessageLike(value: unknown): value is AgentDbMessage {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { id?: unknown }).id === 'string' &&
'role' in value
);
}
function isRestorableMessage(
value: Record<string, unknown> & { createdAt: Date },
): value is AgentDbMessage & Record<string, unknown> {
@@ -152,32 +140,6 @@ function expandRunIdsToGroups(
return [...expanded];
}
function collectInFlightCheckpointMessages(checkpoints: InstanceAiCheckpoint[]): AgentDbMessage[] {
const merged: AgentDbMessage[] = [];
const seen = new Set<string>();
for (const checkpoint of checkpoints) {
const stateMessages = checkpoint.state?.messageList?.messages ?? [];
for (const candidate of stateMessages) {
if (!isAgentMessageLike(candidate) || seen.has(candidate.id)) continue;
seen.add(candidate.id);
merged.push({
...candidate,
createdAt:
candidate.createdAt instanceof Date ? candidate.createdAt : new Date(candidate.createdAt),
});
}
}
return merged;
}
function mergeMessagesById(stored: AgentDbMessage[], extras: AgentDbMessage[]): AgentDbMessage[] {
if (extras.length === 0) return stored;
const byId = new Map<string, AgentDbMessage>();
for (const message of stored) byId.set(message.id, message);
for (const message of extras) if (!byId.has(message.id)) byId.set(message.id, message);
return [...byId.values()].sort((a, b) => messageCreatedAtMs(a) - messageCreatedAtMs(b));
}
/** Runs with a `run-start` fact but no terminal `run-finish` in the log. */
function collectUnfinishedRunIds(rows: Array<{ runId: string; event: InstanceAiEvent }>) {
const unfinished = new Set<string>();
@@ -210,14 +172,12 @@ function collectSuspendedHostRunIds(checkpoints: InstanceAiCheckpoint[]): Set<st
* run-sync bootstrap and the snapshot writer feed it. The parser pairs
* entries to assistant messages positionally by createdAt, so the entry is
* anchored at the FIRST run's last fact time ( parent-run end, the moment
* a stored snapshot row would have been created). `skippedInFlight` reports
* whether run/group exclusion dropped any rows, so the caller can tell an
* exclusion-emptied fold apart from a thread with nothing renderable. */
* a stored snapshot row would have been created). */
function buildLogDerivedSnapshots(
rows: Array<{ runId: string; createdAt: Date; event: InstanceAiEvent }>,
skipRunIds: Set<string>,
skipGroupIds: Set<string>,
): { entries: AgentTreeSnapshot[]; skippedInFlight: boolean } {
): { entries: AgentTreeSnapshot[] } {
// A run's run-start is its first fact, so the run-to-group mapping is
// complete before any grouping decision needs it.
const groupKeyByRun = new Map<string, string>();
@@ -252,15 +212,11 @@ function buildLogDerivedSnapshots(
lastAt: Date;
};
const groups = new Map<string, Group>();
let skippedInFlight = false;
for (const row of rows) {
if (!row.runId) continue;
const messageGroupId = groupKeyByRun.get(row.runId);
const key = messageGroupId ?? row.runId;
if (skipRunIds.has(row.runId) || skipGroupKeys.has(key)) {
skippedInFlight = true;
continue;
}
if (skipRunIds.has(row.runId) || skipGroupKeys.has(key)) continue;
let group = groups.get(key);
if (!group) {
group = {
@@ -297,7 +253,7 @@ function buildLogDerivedSnapshots(
updatedAt: group.lastAt,
});
}
return { entries, skippedInFlight };
return { entries };
}
@Service()
@@ -308,7 +264,6 @@ export class InstanceAiMemoryService {
private readonly logger: Logger,
globalConfig: GlobalConfig,
private readonly agentMemory: TypeORMAgentMemory,
private readonly dbSnapshotStorage: DbSnapshotStorage,
private readonly checkpointRepository: InstanceAiCheckpointRepository,
private readonly pendingConfirmationRepository: InstanceAiPendingConfirmationRepository,
private readonly eventLogRepository: InstanceAiEventLogRepository,
@@ -443,64 +398,27 @@ export class InstanceAiMemoryService {
// Hydrate trees only for the page we are about to render.
const pageWindow = historyWindow(result.messages, page, result.newerBoundaryAt);
const loadStoredSnapshots = async (): Promise<AgentTreeSnapshot[]> => {
if (!pageWindow) return [];
let snapshots = await this.dbSnapshotStorage
.getForWindow(threadId, pageWindow)
.catch((error) => {
this.logger.warn('Failed to load agent tree snapshots', {
threadId,
error: error instanceof Error ? error.message : String(error),
});
return [] as AgentTreeSnapshot[];
});
// Exclude snapshots for active runs — they have no matching assistant
// message in memory yet and would misalign the positional
// snapshot-to-message matching in parseStoredMessages.
if (options?.excludeRunIds?.length) {
const excluded = new Set(options.excludeRunIds);
snapshots = snapshots.filter((s) => !excluded.has(s.runId));
}
return snapshots;
};
// Loaded once, shared by the fold's suspension carve-out and the
// in-flight message merge below.
// The fold's suspension carve-out: a HITL-suspended run legitimately has
// no run-finish, so its turn still folds (the confirmation card and the
// in-flight work are durable facts) instead of being skipped as in-flight.
const activeCheckpoints = await this.loadActiveCheckpoints(threadId);
// No window means an out-of-range older page: it has no message rows for
// a tree to pair with, and hydrating it unbounded would read the whole
// thread to render nothing.
//
// Fold-on-read: history trees derive from the event log. Stored snapshots
// are only loaded for the fold's pre-log/failure fallback, keeping the
// heaviest instance-ai table out of the hot path.
// Fold-on-read: history trees derive from the event log.
const snapshots = !pageWindow
? []
: await this.foldSnapshotsFromLog(
threadId,
loadStoredSnapshots,
collectSuspendedHostRunIds(activeCheckpoints),
pageWindow,
options?.excludeRunIds,
options?.excludeMessageGroupIds,
);
// Surface the in-flight messages from any suspended checkpoint. The
// user's prompt is persisted to memory on receipt, but the intermediate
// assistant responses and pending tool-call from a turn suspended at HITL
// are only committed after the turn completes, so until then they live
// only inside the checkpoint blob. Without merging them in, a thread
// waiting on a confirmation renders without those in-flight artifacts
// after a page reload.
const checkpointMessages = collectInFlightCheckpointMessages(activeCheckpoints);
const storedMessages = mergeMessagesById(result.messages, checkpointMessages);
const fallbacksBefore = messageParserStats.fallbackActivations;
const messages = parseStoredMessages(storedMessages, snapshots);
this.durableLogMetrics.notifyParserFallbacks(
messageParserStats.fallbackActivations - fallbacksBefore,
);
const messages = parseStoredMessages(result.messages, snapshots);
await this.flagExpiredConfirmations(messages);
const projectId = await this.agentMemory.getThreadProjectId(threadId);
@@ -508,10 +426,7 @@ export class InstanceAiMemoryService {
}
/**
* Fold-on-read: history agent trees derive from the event log. Stored
* snapshot rows keep being written but are neither read nor loaded here;
* the lazy loader runs only when the thread has no log rows or the read
* fails/derives nothing.
* Fold-on-read: history agent trees derive from the event log.
*
* Only the runs behind the requested page are read and folded, so a long
* thread costs the same per read as a short one. Run-start facts are
@@ -521,7 +436,6 @@ export class InstanceAiMemoryService {
*/
private async foldSnapshotsFromLog(
threadId: string,
loadStoredSnapshots: () => Promise<AgentTreeSnapshot[]>,
suspendedRunIds: ReadonlySet<string>,
pageWindow: HistoryWindow,
excludeRunIds?: string[],
@@ -530,14 +444,8 @@ export class InstanceAiMemoryService {
const start = Date.now();
let rows;
try {
// Pre-log thread: no run has a start fact, so stored snapshots still
// render. The backfill migration gave every pre-existing run event
// rows, so this branch is a dev-instance safety, not a design.
// Checked on run starts rather than
// on the windowed rows, which are also empty for a thread whose log
// simply has nothing inside the page.
const runStarts = await this.eventLogRepository.getRunStarts(threadId);
if (runStarts.length === 0) return await loadStoredSnapshots();
if (runStarts.length === 0) return [];
const windowedRunIds = await this.eventLogRepository.findRunIdsInWindow(threadId, pageWindow);
rows = await this.eventLogRepository.getForThreadRuns(
@@ -545,13 +453,14 @@ export class InstanceAiMemoryService {
expandRunIdsToGroups(windowedRunIds, runStarts),
);
} catch (error) {
// Degrade to messages-without-trees rather than failing the page read.
this.logger.warn('Failed to read Instance AI event log for history', {
threadId,
error: error instanceof Error ? error.message : String(error),
});
return await loadStoredSnapshots();
return [];
}
if (rows.length === 0) return await loadStoredSnapshots();
if (rows.length === 0) return [];
// Multi-main backstop (INS-913): the caller's exclusions come from
// per-process run state, which is empty on a main that is not driving
@@ -567,20 +476,12 @@ export class InstanceAiMemoryService {
if (!suspendedRunIds.has(runId)) skipRunIds.add(runId);
}
const { entries, skippedInFlight } = buildLogDerivedSnapshots(
const { entries } = buildLogDerivedSnapshots(
rows,
skipRunIds,
new Set(excludeMessageGroupIds ?? []),
);
if (entries.length === 0) {
// Emptied by exclusion: the thread's only renderable content is the
// in-flight group. Render nothing rather than fall back — the loader
// filters stored snapshots by exact runId only, so a completed
// sibling's snapshot would resurrect exactly the in-flight group
// state the exclusion keeps out of history.
if (skippedInFlight) return [];
return await loadStoredSnapshots();
}
if (entries.length === 0) return [];
entries.sort((a, b) => (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0));
this.durableLogMetrics.recordFoldRead(Date.now() - start, entries.length);
@@ -621,13 +522,6 @@ export class InstanceAiMemoryService {
}
}
async getLatestRunSnapshot(
threadId: string,
options?: { messageGroupId?: string; runId?: string },
): Promise<AgentTreeSnapshot | undefined> {
return await this.dbSnapshotStorage.getLatest(threadId, options);
}
/**
* Verify that a thread belongs to a specific user.
* Returns true if the thread exists and is owned by the user.
@@ -1,4 +1,4 @@
import type { InstanceAiAgentNode, InstanceAiErrorEvent, InstanceAiEvent } from '@n8n/api-types';
import type { InstanceAiErrorEvent, InstanceAiEvent } from '@n8n/api-types';
import type { Logger } from '@n8n/backend-common';
import type { User } from '@n8n/db';
import {
@@ -21,7 +21,6 @@ import type { Telemetry } from '@/telemetry';
import type { InProcessEventBus } from './event-bus/in-process-event-bus';
import type { InstanceAiErrorReporterService } from './instance-ai-error-reporter.service';
import type { DbSnapshotStorage } from './storage/db-snapshot-storage';
import type { SuspendedThreadPersistenceService } from './suspended-thread-persistence.service';
import type {
InstanceAiTracingService,
@@ -38,55 +37,6 @@ function getBackgroundOutcomeResponseId(outcome: TerminalOutcome): string {
return `background-outcome:${outcome.id}`;
}
function createTerminalOutcomeAgentTree(
outcome: TerminalOutcome,
responseId: string,
): InstanceAiAgentNode {
return {
agentId: orchestratorAgentId(outcome.runId),
role: 'orchestrator',
status:
outcome.status === 'cancelled'
? 'cancelled'
: outcome.status === 'failed'
? 'error'
: 'completed',
textContent: outcome.userFacingMessage,
reasoning: '',
toolCalls: [],
children: [],
timeline: [{ type: 'text', content: outcome.userFacingMessage, responseId }],
};
}
function appendTerminalOutcomeToAgentTree(
tree: InstanceAiAgentNode,
outcome: TerminalOutcome,
responseId: string,
): { tree: InstanceAiAgentNode; appended: boolean } {
const text = outcome.userFacingMessage.trim();
if (!text) return { tree, appended: false };
const alreadyInTimeline = tree.timeline.some(
(entry) => entry.type === 'text' && entry.responseId === responseId,
);
if (alreadyInTimeline) {
return { tree, appended: false };
}
return {
appended: true,
tree: {
...tree,
textContent: tree.textContent ? `${tree.textContent}\n\n${outcome.userFacingMessage}` : text,
timeline: [
...tree.timeline,
{ type: 'text', content: outcome.userFacingMessage, responseId },
],
},
};
}
// The slice of each collaborator the terminal-outcome coordinator actually
// uses. Anchored to the concrete types via `Pick` so the signatures stay in
// sync with the source.
@@ -100,11 +50,6 @@ export type InstanceAiTerminalOutcomeEventBus = Pick<InProcessEventBus, 'publish
): InstanceAiEvent[] | Promise<InstanceAiEvent[]>;
};
export type InstanceAiTerminalOutcomeSnapshotStorage = Pick<
DbSnapshotStorage,
'getLatest' | 'save' | 'updateLast'
>;
export type InstanceAiTerminalOutcomeTelemetry = Pick<Telemetry, 'track'>;
export type InstanceAiTerminalOutcomeErrorReporter = Pick<InstanceAiErrorReporterService, 'report'>;
@@ -126,7 +71,6 @@ export type InstanceAiTerminalOutcomeTracing = Pick<
export interface InstanceAiTerminalOutcomeServiceOptions {
eventBus: InstanceAiTerminalOutcomeEventBus;
dbSnapshotStorage: InstanceAiTerminalOutcomeSnapshotStorage;
agentMemory: PatchableThreadMemory;
telemetry: InstanceAiTerminalOutcomeTelemetry;
errorReporter: InstanceAiTerminalOutcomeErrorReporter;
@@ -144,15 +88,6 @@ export interface InstanceAiTerminalOutcomeServiceOptions {
status: 'completed' | 'cancelled' | 'errored',
reason?: string,
) => void;
/**
* Persists the orchestrator agent-tree snapshot. Owned by the run loop until
* snapshot persistence is extracted into its own collaborator.
*/
saveAgentTreeSnapshot: (
threadId: string,
runId: string,
snapshotStorage: DbSnapshotStorage,
) => Promise<void>;
}
/**
@@ -169,7 +104,7 @@ export interface InstanceAiTerminalOutcomeServiceOptions {
*
* 2. **Terminal-outcome durability.** Background tasks finish out of band from
* the foreground run, so their user-facing summary is persisted to
* {@link TerminalOutcomeStorage} and the conversation snapshot, then
* {@link TerminalOutcomeStorage} and published as a durable text-block, then
* replayed on reconnect so a closed SSE stream never drops the result.
*/
export class InstanceAiTerminalOutcomeService {
@@ -179,8 +114,6 @@ export class InstanceAiTerminalOutcomeService {
private readonly eventBus: InstanceAiTerminalOutcomeEventBus;
private readonly dbSnapshotStorage: InstanceAiTerminalOutcomeSnapshotStorage;
private readonly agentMemory: PatchableThreadMemory;
private readonly telemetry: InstanceAiTerminalOutcomeTelemetry;
@@ -197,11 +130,8 @@ export class InstanceAiTerminalOutcomeService {
private readonly publishRunFinish: InstanceAiTerminalOutcomeServiceOptions['publishRunFinish'];
private readonly saveAgentTreeSnapshot: InstanceAiTerminalOutcomeServiceOptions['saveAgentTreeSnapshot'];
constructor(options: InstanceAiTerminalOutcomeServiceOptions) {
this.eventBus = options.eventBus;
this.dbSnapshotStorage = options.dbSnapshotStorage;
this.agentMemory = options.agentMemory;
this.telemetry = options.telemetry;
this.errorReporter = options.errorReporter;
@@ -210,7 +140,6 @@ export class InstanceAiTerminalOutcomeService {
this.suspendedThreads = options.suspendedThreads;
this.tracing = options.tracing;
this.publishRunFinish = options.publishRunFinish;
this.saveAgentTreeSnapshot = options.saveAgentTreeSnapshot;
}
async evaluateTerminalResponse(
@@ -342,7 +271,6 @@ export class InstanceAiTerminalOutcomeService {
threadId: string;
runId: string;
abortController: AbortController;
snapshotStorage: DbSnapshotStorage;
tracing?: InstanceAiTraceContext;
}): Promise<MessageTraceFinalization> {
this.runState.cancelThread(args.threadId);
@@ -358,7 +286,6 @@ export class InstanceAiTerminalOutcomeService {
'errored',
'I need your input to continue, but I could not display the prompt. Please try again.',
);
await this.saveAgentTreeSnapshot(args.threadId, args.runId, args.snapshotStorage);
return {
status: 'error',
reason: 'invalid_confirmation_payload',
@@ -392,10 +319,7 @@ export class InstanceAiTerminalOutcomeService {
};
}
async replayUndeliveredTerminalOutcomes(
threadId: string,
options: { delivery?: 'snapshot' | 'event' } = {},
): Promise<void> {
async replayUndeliveredTerminalOutcomes(threadId: string): Promise<void> {
const storage = this.createTerminalOutcomeStorage();
const noOutcomes: TerminalOutcome[] = [];
const persistedOutcomes = await storage.getUndelivered(threadId).catch((error) => {
@@ -413,13 +337,12 @@ export class InstanceAiTerminalOutcomeService {
outcomes.set(outcome.id, outcome);
}
const persistedOutcomeIds = new Set(persistedOutcomes.map((outcome) => outcome.id));
const delivery = options.delivery ?? 'snapshot';
for (const outcome of outcomes.values()) {
const responseId = getBackgroundOutcomeResponseId(outcome);
let snapshotDelivered = false;
let delivery: 'published' | 'already-emitted' | 'dropped' = 'dropped';
try {
snapshotDelivered = await this.persistTerminalOutcomeLineToSnapshot(outcome, responseId);
delivery = await this.publishTerminalOutcomeLine(outcome, responseId);
} catch (error) {
this.logger.warn('Failed to replay Instance AI terminal outcome', {
threadId,
@@ -427,29 +350,11 @@ export class InstanceAiTerminalOutcomeService {
taskId: outcome.taskId,
error: getErrorMessage(error),
});
if (delivery === 'event') {
const published = await this.publishTerminalOutcomeLine(outcome, responseId);
this.telemetry.track('instance_ai_terminal_response_decision', {
thread_id: threadId,
run_id: outcome.runId,
message_group_id: outcome.messageGroupId,
task_id: outcome.taskId,
source: 'terminal_outcome_replay',
status: outcome.status,
action: published ? 'replay_event' : 'already-emitted',
visibility_source: 'background-outcome',
});
}
continue;
}
// Left undelivered on purpose: the next replay retries it.
if (delivery === 'dropped') continue;
if (!snapshotDelivered) continue;
let action = 'replay_snapshot';
if (delivery === 'event') {
const published = await this.publishTerminalOutcomeLine(outcome, responseId);
action = published ? 'replay_event' : 'already-emitted';
}
const action = delivery === 'published' ? 'replay_event' : 'already-emitted';
if (persistedOutcomeIds.has(outcome.id)) {
await storage
@@ -477,47 +382,23 @@ export class InstanceAiTerminalOutcomeService {
}
}
private async persistTerminalOutcomeLineToSnapshot(
outcome: TerminalOutcome,
responseId: string,
): Promise<boolean> {
const snapshot = await this.dbSnapshotStorage.getLatest(outcome.threadId, {
messageGroupId: outcome.messageGroupId,
runId: outcome.runId,
});
if (!snapshot) {
await this.dbSnapshotStorage.save(
outcome.threadId,
createTerminalOutcomeAgentTree(outcome, responseId),
outcome.runId,
{
messageGroupId: outcome.messageGroupId,
runIds: [outcome.runId],
},
);
return true;
}
const { tree } = appendTerminalOutcomeToAgentTree(snapshot.tree, outcome, responseId);
const runIds = new Set(snapshot.runIds ?? [snapshot.runId]);
runIds.add(outcome.runId);
await this.dbSnapshotStorage.updateLast(outcome.threadId, tree, snapshot.runId, {
messageGroupId: snapshot.messageGroupId ?? outcome.messageGroupId,
runIds: [...runIds],
langsmithRunId: snapshot.langsmithRunId,
langsmithTraceId: snapshot.langsmithTraceId,
});
return true;
}
/**
* Publish the outcome line as a durable text-block and read it back.
* `publish` only enqueues the drain persists asynchronously and settles
* flush waiters even when it had to drop a batch so only the read-back
* makes the line trustworthy as a delivery record. 'dropped' means it never
* reached the log; the caller must leave the outcome undelivered so a later
* replay retries it.
*/
private async publishTerminalOutcomeLine(
outcome: TerminalOutcome,
responseId: string,
): Promise<boolean> {
): Promise<'published' | 'already-emitted' | 'dropped'> {
const isOutcomeLine = (event: InstanceAiEvent) => event.responseId === responseId;
const alreadyPublished = (
await this.eventBus.getEventsForRun(outcome.threadId, outcome.runId)
).some((event) => event.responseId === responseId);
if (alreadyPublished) return false;
).some(isOutcomeLine);
if (alreadyPublished) return 'already-emitted';
this.eventBus.publish(outcome.threadId, {
type: 'text-block',
@@ -526,7 +407,12 @@ export class InstanceAiTerminalOutcomeService {
responseId,
payload: { text: outcome.userFacingMessage },
});
return true;
// The adapter's read settles the thread's drain before querying, so the
// block is either in the log by now or was dropped.
const durable = (await this.eventBus.getEventsForRun(outcome.threadId, outcome.runId)).some(
isOutcomeLine,
);
return durable ? 'published' : 'dropped';
}
async recordBackgroundTerminalOutcome(task: ManagedBackgroundTask): Promise<void> {
@@ -553,7 +439,29 @@ export class InstanceAiTerminalOutcomeService {
}
const responseId = getBackgroundOutcomeResponseId(outcome);
const published = await this.publishTerminalOutcomeLine(outcome, responseId);
let delivery: 'published' | 'already-emitted' | 'dropped' = 'dropped';
try {
delivery = await this.publishTerminalOutcomeLine(outcome, responseId);
} catch (error) {
this.logger.warn('Failed to publish Instance AI terminal outcome line', {
threadId: task.threadId,
runId: task.runId,
taskId: task.taskId,
error: getErrorMessage(error),
});
}
if (delivery === 'dropped') {
// Leave the outcome undelivered — the metadata row (or the pending-map
// entry when the upsert failed too) makes the next replay retry it.
this.telemetry.track('instance_ai_terminal_outcome_persistence_failure', {
thread_id: task.threadId,
run_id: task.runId,
task_id: task.taskId,
status: outcome.status,
phase: 'event',
});
return;
}
this.telemetry.track('instance_ai_terminal_response_decision', {
thread_id: task.threadId,
@@ -562,30 +470,11 @@ export class InstanceAiTerminalOutcomeService {
task_id: task.taskId,
source: 'background_outcome',
status: outcome.status,
action: published ? 'emit' : 'already-emitted',
action: delivery === 'published' ? 'emit' : 'already-emitted',
visibility_source: 'background-outcome',
});
let snapshotDelivered = false;
try {
snapshotDelivered = await this.persistTerminalOutcomeLineToSnapshot(outcome, responseId);
} catch (error) {
this.logger.warn('Failed to persist Instance AI terminal outcome line to snapshot', {
threadId: task.threadId,
runId: task.runId,
taskId: task.taskId,
error: getErrorMessage(error),
});
this.telemetry.track('instance_ai_terminal_outcome_persistence_failure', {
thread_id: task.threadId,
run_id: task.runId,
task_id: task.taskId,
status: outcome.status,
phase: 'snapshot',
});
}
if (!persisted || !snapshotDelivered) return;
if (!persisted) return;
try {
await this.createTerminalOutcomeStorage().markDelivered(
@@ -23,11 +23,7 @@ import {
InstanceAiEvalSeedDataTableRowsRequest,
findUnbackedSeedWorkflowTools,
} from '@n8n/api-types';
import type {
InstanceAiAdminSettingsResponse,
InstanceAiAgentNode,
InstanceAiEvent,
} from '@n8n/api-types';
import type { InstanceAiAdminSettingsResponse, InstanceAiEvent } from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
import { AuthenticatedRequest, User, UserRepository } from '@n8n/db';
@@ -46,7 +42,7 @@ import {
Body,
Query,
} from '@n8n/decorators';
import type { AgentTreeSnapshot, StoredEvent } from '@n8n/instance-ai';
import type { StoredEvent } from '@n8n/instance-ai';
import {
buildAgentTreeFromEvents,
clearedAgentBuilderTargetMetadata,
@@ -94,36 +90,6 @@ const KEEP_ALIVE_INTERVAL_MS = 15_000;
export class InstanceAiController {
private readonly gatewayApiKey: string;
private static getTreeRichnessScore(tree: InstanceAiAgentNode): number {
let score = 0;
const stack = [tree];
while (stack.length > 0) {
const node = stack.pop()!;
score += 100;
score += node.toolCalls.length * 10;
score += node.timeline.length * 2;
score += (node.planItems?.length ?? 0) * 20;
score += node.toolCalls.filter((toolCall) => toolCall.confirmation).length * 50;
score += node.children.length * 25;
stack.push(...node.children);
}
return score;
}
private static selectBootstrapTree(
eventTree: InstanceAiAgentNode,
persistedTree?: InstanceAiAgentNode,
): InstanceAiAgentNode {
if (!persistedTree) return eventTree;
return InstanceAiController.getTreeRichnessScore(persistedTree) >
InstanceAiController.getTreeRichnessScore(eventTree)
? persistedTree
: eventTree;
}
constructor(
private readonly instanceAiService: InstanceAiService,
private readonly gatewayService: InstanceAiGatewayService,
@@ -349,9 +315,7 @@ export class InstanceAiController {
// 2. Re-publish any terminal outcomes that never reached the client.
if (ownership === 'owned') {
await this.instanceAiService.replayUndeliveredTerminalOutcomes(threadId, {
delivery: 'event',
});
await this.instanceAiService.replayUndeliveredTerminalOutcomes(threadId);
}
// 3. Set SSE headers.
@@ -373,7 +337,7 @@ export class InstanceAiController {
const cursor =
Number.isFinite(parsedHeader) && parsedHeader >= 0 ? parsedHeader : (query.lastEventId ?? 0);
// 5. Collect live message groups and fetch their persisted snapshots.
// 5. Collect live message groups.
// Multiple groups can be active simultaneously when a background task
// from an older turn outlives its original turn.
const threadStatus = this.instanceAiService.getThreadStatus(threadId);
@@ -406,23 +370,6 @@ export class InstanceAiController {
}
}
const persistedSnapshots = new Map<string, AgentTreeSnapshot | undefined>();
for (const [groupId, group] of liveGroups) {
persistedSnapshots.set(
groupId,
await this.memoryService.getLatestRunSnapshot(threadId, {
messageGroupId: groupId,
// Use the group's own latest runId — NOT the thread-global
// activeRunId, which belongs to the current orchestrator turn and
// would be wrong for background groups from older turns.
runId: group.runIds.at(-1),
}),
);
}
// The client may have disconnected during the awaits above.
if (closed) return;
// 6b (used by both arms below). Emit one run-sync control frame for a live
// message group. Each frame uses a named SSE event type
// (event: run-sync) with NO id: field so the browser's lastEventId is
@@ -432,14 +379,9 @@ export class InstanceAiController {
group: { runIds: string[]; status: 'active' | 'suspended' | 'background' },
runEvents: InstanceAiEvent[],
) => {
const persistedSnapshot = persistedSnapshots.get(groupId);
if (runEvents.length === 0 && !persistedSnapshot) return;
if (runEvents.length === 0) return;
const eventTree = buildAgentTreeFromEvents(runEvents);
const agentTree = InstanceAiController.selectBootstrapTree(
eventTree,
persistedSnapshot?.tree,
);
const agentTree = buildAgentTreeFromEvents(runEvents);
res.write(
`event: run-sync\ndata: ${JSON.stringify({
runId: group.runIds.at(-1),
@@ -85,7 +85,6 @@ export class InstanceAiModule implements ModuleInterface {
const { InstanceAiThread } = await import('./entities/instance-ai-thread.entity.js');
const { InstanceAiMessage } = await import('./entities/instance-ai-message.entity.js');
const { InstanceAiResource } = await import('./entities/instance-ai-resource.entity.js');
const { InstanceAiRunSnapshot } = await import('./entities/instance-ai-run-snapshot.entity.js');
const { InstanceAiIterationLog } = await import(
'./entities/instance-ai-iteration-log.entity.js'
);
@@ -112,7 +111,6 @@ export class InstanceAiModule implements ModuleInterface {
InstanceAiThread,
InstanceAiMessage,
InstanceAiResource,
InstanceAiRunSnapshot,
InstanceAiIterationLog,
InstanceAiCheckpoint,
InstanceAiPendingConfirmation,
@@ -52,7 +52,6 @@ import {
createDomainAccessTracker,
BackgroundTaskManager,
MemoryTaskRegistry,
buildAgentTreeFromEvents,
classifyAttachments,
buildAttachmentManifest,
getDateTimeSection,
@@ -187,11 +186,11 @@ import {
type PlannedWorkflowVerificationGate,
type PlannedWorkflowVerificationTracker,
} from './planned-task-action-runner';
import { InstanceAiEventLogRepository } from './repositories/instance-ai-event-log.repository';
import { InstanceAiPendingConfirmationRepository } from './repositories/instance-ai-pending-confirmation.repository';
import { InstanceAiThreadGrantRepository } from './repositories/instance-ai-thread-grant.repository';
import { InstanceAiSandboxService, type RuntimeSandboxEntry } from './sandbox';
import { DbIterationLogStorage } from './storage/db-iteration-log-storage';
import { DbSnapshotStorage } from './storage/db-snapshot-storage';
import { TypeORMAgentCheckpointStore } from './storage/typeorm-agent-checkpoint-store';
import { TypeORMAgentMemory } from './storage/typeorm-agent-memory';
import { isStreamTransportError } from './stream-transport-error';
@@ -630,7 +629,6 @@ type UnclaimedResumeContext = {
runId: string;
user: User;
signal: AbortSignal;
snapshotStorage: DbSnapshotStorage;
tracing?: InstanceAiTraceContext;
messageGroupId?: string;
unregisteredResumeTracing?: InstanceAiTraceContext;
@@ -823,7 +821,7 @@ export class InstanceAiService {
private readonly threadGrantRepo: InstanceAiThreadGrantRepository,
private readonly pendingConfirmationRepo: InstanceAiPendingConfirmationRepository,
private readonly urlService: UrlService,
private readonly dbSnapshotStorage: DbSnapshotStorage,
private readonly eventLogRepository: InstanceAiEventLogRepository,
private readonly dbIterationLogStorage: DbIterationLogStorage,
private readonly instanceWriteAccess: InstanceWriteAccessService,
private readonly telemetry: Telemetry,
@@ -862,7 +860,6 @@ export class InstanceAiService {
logger: this.logger,
pendingConfirmationRepo: this.pendingConfirmationRepo,
runState: this.runState,
dbSnapshotStorage: this.dbSnapshotStorage,
eventBus: this.eventBus,
rebuilder: {
rebuildSuspendedRun: this.rebuildSuspendedRunFromCheckpoint.bind(this),
@@ -894,7 +891,7 @@ export class InstanceAiService {
getEventsForRun: async (threadId, runId) => await this.readRunEvents(threadId, [runId]),
},
runState: this.runState,
dbSnapshotStorage: this.dbSnapshotStorage,
eventLog: this.eventLogRepository,
aiService: this.aiService,
});
this.sandboxService = new InstanceAiSandboxService({
@@ -915,7 +912,6 @@ export class InstanceAiService {
getEventsForRun: async (threadId, runId) => await this.readRunEvents(threadId, [runId]),
getEventsForRuns: async (threadId, runIds) => await this.readRunEvents(threadId, runIds),
},
dbSnapshotStorage: this.dbSnapshotStorage,
agentMemory: this.agentMemory,
telemetry: this.telemetry,
errorReporter: this.instanceAiErrorReporter,
@@ -926,8 +922,6 @@ export class InstanceAiService {
publishRunFinish: (threadId, runId, status, reason) => {
this.publishRunFinish(threadId, runId, status, reason);
},
saveAgentTreeSnapshot: async (threadId, runId, snapshotStorage) =>
await this.saveAgentTreeSnapshot(threadId, runId, snapshotStorage),
});
this.defaultTimeZone = globalConfig.generic.timezone;
const restEndpoint = globalConfig.endpoints.rest;
@@ -1434,15 +1428,7 @@ export class InstanceAiService {
status: 'cancelled',
},
});
void this.terminalOutcome.recordBackgroundTerminalOutcome(task).finally(() => {
void this.saveAgentTreeSnapshot(
threadId,
task.runId,
this.dbSnapshotStorage,
true,
task.messageGroupId,
);
});
void this.terminalOutcome.recordBackgroundTerminalOutcome(task);
if (user) {
void this.handlePlannedTaskSettlement(user, task, 'cancelled', { reschedule: false });
}
@@ -1498,18 +1484,7 @@ export class InstanceAiService {
payload: { role: task.role, result: '', status: 'cancelled' },
});
// Persist the updated agent tree so cancelled status survives page reload.
// The onSettled callback in executeTask is skipped for aborted tasks,
// so we must save the snapshot explicitly here.
void this.terminalOutcome.recordBackgroundTerminalOutcome(task).finally(() => {
void this.saveAgentTreeSnapshot(
threadId,
task.runId,
this.dbSnapshotStorage,
true,
task.messageGroupId,
);
});
void this.terminalOutcome.recordBackgroundTerminalOutcome(task);
const user = this.runState.getThreadUser(threadId);
if (user) {
@@ -1733,13 +1708,6 @@ export class InstanceAiService {
},
onSettled: async (task) => {
await this.terminalOutcome.recordBackgroundTerminalOutcome(task);
await this.saveAgentTreeSnapshot(
threadId,
runId,
this.dbSnapshotStorage,
true,
messageGroupId,
);
},
});
@@ -1880,10 +1848,9 @@ export class InstanceAiService {
// `instance_ai_pending_confirmations` row survives the restart and
// `handleOrphanedConfirmation` will issue the user-visible
// `restart_lost_confirmation` UserError + `run-finish` when (if)
// the user clicks confirm. If we publish run-finish + re-save the
// snapshot here, we'd permanently overwrite the plan/ask card with
// a `status: 'cancelled'` tree before the user has a chance to see
// it on reload.
// the user clicks confirm. If we publish run-finish here, the fold
// would render the plan/ask card as cancelled before the user has a
// chance to see it on reload.
if (threadsWithPendingHitl.has(run.threadId)) {
await this.tracing.finalizeRunTracing(run.runId, run.tracing, {
status: 'cancelled',
@@ -1892,20 +1859,16 @@ export class InstanceAiService {
// Record the policy *before* the abort fires so the run's catch
// handler (which runs synchronously off the abort) sees the
// flag. The catch path consults `shouldPreserveHitlOnShutdown`
// and skips the terminal-fallback / run-finish / snapshot
// writes that would otherwise overwrite the plan/ask card.
// and skips the terminal-fallback / run-finish writes that
// would otherwise overwrite the plan/ask card.
this.preserveHitlOnShutdown.add(run.runId);
run.abortController.abort();
continue;
}
// Truly mid-stream run: publish run-finish first so the terminal
// event lands in the event bus before the snapshot reads it;
// saveAgentTreeSnapshot rebuilds the tree from the bus, so without
// this order the persisted tree would still look mid-stream after
// the process is gone.
// Truly mid-stream run: the durable run-finish is all history needs —
// the fold derives the terminal tree from the log after restart.
this.publishRunFinish(run.threadId, run.runId, 'cancelled', 'service_shutdown');
await this.persistShutdownSnapshot(run.threadId, run.runId, run.messageGroupId);
await this.tracing.finalizeRunTracing(run.runId, run.tracing, {
status: 'cancelled',
reason: 'service_shutdown',
@@ -2128,33 +2091,6 @@ export class InstanceAiService {
}
}
/**
* Save the in-flight agent tree as a terminal snapshot so the UI doesn't
* sit on a half-rendered turn after the process restarts. Best-effort: a
* DB write failure here must not block the rest of shutdown.
*/
private async persistShutdownSnapshot(
threadId: string,
runId: string,
messageGroupId: string | undefined,
): Promise<void> {
try {
await this.saveAgentTreeSnapshot(
threadId,
runId,
this.dbSnapshotStorage,
true,
messageGroupId,
);
} catch (error: unknown) {
this.logger.warn('Failed to persist shutdown snapshot', {
threadId,
runId,
error: getErrorMessage(error),
});
}
}
private createAgentMemoryOptions(user: User, threadId: string, runId: string) {
return {
observationalMemory: {
@@ -2345,11 +2281,8 @@ export class InstanceAiService {
* reconnecting client never misses a result that completed while its stream
* was closed. Delegates to {@link InstanceAiTerminalOutcomeService}.
*/
async replayUndeliveredTerminalOutcomes(
threadId: string,
options: { delivery?: 'snapshot' | 'event' } = {},
): Promise<void> {
await this.terminalOutcome.replayUndeliveredTerminalOutcomes(threadId, options);
async replayUndeliveredTerminalOutcomes(threadId: string): Promise<void> {
await this.terminalOutcome.replayUndeliveredTerminalOutcomes(threadId);
}
private async syncPlannedTasksToUi(threadId: string, graph: PlannedTaskGraph): Promise<void> {
@@ -2545,7 +2478,6 @@ export class InstanceAiService {
const taskStorage = new ThreadTaskStorage(memory);
const iterationLog = this.dbIterationLogStorage;
const snapshotStorage = this.dbSnapshotStorage;
const workflowLoopStorage = new WorkflowLoopStorage(memory);
const workflowTasks = this.createWorkflowTaskServiceWithUiSync(
threadId,
@@ -2653,7 +2585,6 @@ export class InstanceAiService {
checkpointStore: this.checkpointStore,
eventBus: this.eventBus,
logger: this.logger,
outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
trackTelemetry: (eventName, properties) => {
this.telemetry.track(eventName, redactTelemetryProperties(properties));
},
@@ -2714,19 +2645,10 @@ export class InstanceAiService {
messageGroupId,
kind: 'inline',
});
// Inline HITL (plan approval / sub-agent asks)
// keeps the orchestrator run active, so the normal suspended/completed
// snapshot paths do not execute. Queue a snapshot after the current
// confirmation-request event is published to preserve refresh recovery.
queueMicrotask(() => {
void this.saveAgentTreeSnapshot(threadId, runId, snapshotStorage);
});
});
},
cancelBackgroundTask: async (taskId) => this.cancelBackgroundTask(threadId, taskId),
spawnBackgroundTask: (opts) =>
this.spawnBackgroundTask(runId, opts, snapshotStorage, messageGroupId),
spawnBackgroundTask: (opts) => this.spawnBackgroundTask(runId, opts, messageGroupId),
touchRun: () => this.runState.touchActiveRun(threadId),
touchBackgroundTask: (taskId) => this.backgroundTasks.touchTask(threadId, taskId),
plannedTaskService,
@@ -2748,7 +2670,6 @@ export class InstanceAiService {
memory,
taskStorage,
iterationLog,
snapshotStorage,
workflowTasks,
plannedTaskService,
modelId,
@@ -3665,7 +3586,6 @@ export class InstanceAiService {
let tracing: InstanceAiTraceContext | undefined;
let messageTraceFinalization: MessageTraceFinalization | undefined;
let aiCreatedWorkflowIds: Set<string> | undefined;
let activeSnapshotStorage: DbSnapshotStorage | undefined;
let messageId = '';
let streamReached = false;
/** Declared out here so the terminal handlers below can see it. */
@@ -3766,14 +3686,24 @@ export class InstanceAiService {
this.runDebugBuffer.ensure(runId, threadId, buildRunDebugLabel({ message, resumeReason }));
}
// Publish run-start (includes userId for audit trail attribution)
// Publish run-start (includes userId for audit trail attribution). The
// LangSmith ids ride here so user feedback can annotate the trace after
// a restart — the durable log is their only home.
const traceId = tracing?.rootRun.otelTraceId;
const langsmithRunId = tracing?.rootRun.id;
const langsmithTraceId = tracing?.rootRun.traceId;
this.eventBus.publish(threadId, {
type: 'run-start',
runId,
agentId: orchestratorAgentId(runId),
userId: user.id,
payload: { messageId, messageGroupId, ...(traceId ? { traceId } : {}) },
payload: {
messageId,
messageGroupId,
...(traceId ? { traceId } : {}),
...(langsmithRunId ? { langsmithRunId } : {}),
...(langsmithTraceId ? { langsmithTraceId } : {}),
},
});
// Check if already cancelled before starting agent work
@@ -3802,13 +3732,10 @@ export class InstanceAiService {
executionPushRef,
proxyRunConfig,
);
activeSnapshotStorage = environment.snapshotStorage;
const {
context,
memory,
taskStorage,
snapshotStorage,
workflowTasks,
plannedTaskService,
modelId,
@@ -4110,7 +4037,6 @@ export class InstanceAiService {
logger: this.logger,
onActivity: () => this.runState.touchActiveRun(threadId),
stopSignal,
outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
});
})
: await streamAgentRun(agent as StreamableAgent, streamInput, streamOptions, {
@@ -4122,7 +4048,6 @@ export class InstanceAiService {
logger: this.logger,
onActivity: () => this.runState.touchActiveRun(threadId),
stopSignal,
outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
});
if (result.status === 'suspended') {
// finalizeRun only fires on terminal outcomes; record suspended-segment usage here.
@@ -4206,7 +4131,6 @@ export class InstanceAiService {
threadId,
runId,
abortController,
snapshotStorage,
tracing,
});
return;
@@ -4217,10 +4141,6 @@ export class InstanceAiService {
this.eventBus.publish(threadId, result.confirmationEvent);
}
// Persist the agent tree so the confirmation UI survives page refresh.
// The tree is rebuilt from in-memory events and includes the
// confirmation-request data that the frontend needs.
await this.saveAgentTreeSnapshot(threadId, runId, snapshotStorage);
const suspensionOutputs = buildSuspensionTraceOutputs(runId, result.suspension);
await this.tracing.finalizeRunTracing(runId, tracing, {
status: 'suspended',
@@ -4331,7 +4251,7 @@ export class InstanceAiService {
aiCreatedWorkflowIds,
this.backgroundTasks.getRunningTasks(threadId).length,
);
await this.finalizeRun(threadId, runId, result.status, snapshotStorage, {
await this.finalizeRun(threadId, runId, result.status, {
userId: user.id,
modelId,
archivedWorkflowIds,
@@ -4421,9 +4341,6 @@ export class InstanceAiService {
archivedWorkflowIds,
user.id,
);
if (activeSnapshotStorage) {
await this.saveAgentTreeSnapshot(threadId, runId, activeSnapshotStorage);
}
return;
}
@@ -4497,9 +4414,6 @@ export class InstanceAiService {
user.id,
{ errorMessage, errorSource: 'exception' },
);
if (activeSnapshotStorage) {
await this.saveAgentTreeSnapshot(threadId, runId, activeSnapshotStorage);
}
} finally {
this.runState.clearActiveRun(threadId);
const segmentSuspended = messageTraceFinalization?.status === 'suspended';
@@ -5337,7 +5251,6 @@ export class InstanceAiService {
errorInfo: { errorMessage: rebuildFailure, errorSource: 'exception' },
messageGroupId,
user: activeUser,
snapshotStorage: this.dbSnapshotStorage,
});
this.runState.clearActiveRun(threadId, resumeExecutionToken);
return null;
@@ -5357,7 +5270,6 @@ export class InstanceAiService {
suspendPayload,
signal: abortController.signal,
abortController,
snapshotStorage: this.dbSnapshotStorage,
tracing: effectiveTracing,
orchestrationContext: resumeOrchestrationContext,
modelId: resumeModelId,
@@ -5391,7 +5303,6 @@ export class InstanceAiService {
suspendPayload?: Record<string, unknown>;
signal: AbortSignal;
abortController: AbortController;
snapshotStorage: DbSnapshotStorage;
tracing?: InstanceAiTraceContext;
orchestrationContext?: OrchestrationContext;
modelId?: ModelConfig;
@@ -5489,7 +5400,6 @@ export class InstanceAiService {
agentRunId: opts.agentRunId,
onActivity: () => this.runState.touchActiveRun(opts.threadId),
stopSignal,
outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
});
})
: await resumeAgentRun(agent, resumeData, resumeOptions, {
@@ -5502,7 +5412,6 @@ export class InstanceAiService {
agentRunId: opts.agentRunId,
onActivity: () => this.runState.touchActiveRun(opts.threadId),
stopSignal,
outputRedaction: false, // raw-at-rest: the redactor defaults ON when omitted (INS-837)
});
if (!resumeClaimed) {
skipPostRunCleanup = true;
@@ -5591,7 +5500,6 @@ export class InstanceAiService {
threadId: opts.threadId,
runId: opts.runId,
abortController: opts.abortController,
snapshotStorage: opts.snapshotStorage,
tracing: opts.tracing,
});
return;
@@ -5602,9 +5510,6 @@ export class InstanceAiService {
this.eventBus.publish(opts.threadId, result.confirmationEvent);
}
// Persist the refreshed agent tree so repeated HITL waits
// survive page refresh after a resume as well.
await this.saveAgentTreeSnapshot(opts.threadId, opts.runId, opts.snapshotStorage);
const suspensionOutputs = buildSuspensionTraceOutputs(opts.runId, result.suspension);
await this.tracing.finalizeRunTracing(opts.runId, opts.tracing, {
status: 'suspended',
@@ -5718,7 +5623,7 @@ export class InstanceAiService {
undefined,
this.backgroundTasks.getRunningTasks(opts.threadId).length,
);
await this.finalizeRun(opts.threadId, opts.runId, result.status, opts.snapshotStorage, {
await this.finalizeRun(opts.threadId, opts.runId, result.status, {
userId: opts.user.id,
// Forward modelId so title refinement fires on the resume path too — a run
// that suspends for HITL and completes here would otherwise never be titled.
@@ -5815,7 +5720,6 @@ export class InstanceAiService {
archivedWorkflowIds,
opts.user.id,
);
await this.saveAgentTreeSnapshot(opts.threadId, opts.runId, opts.snapshotStorage);
return;
}
@@ -5892,7 +5796,6 @@ export class InstanceAiService {
opts.user.id,
{ errorMessage, errorSource: 'exception' },
);
await this.saveAgentTreeSnapshot(opts.threadId, opts.runId, opts.snapshotStorage);
} finally {
this.runState.clearActiveRun(opts.threadId, opts.resumeExecutionToken);
const segmentSuspended = messageTraceFinalization?.status === 'suspended';
@@ -6008,7 +5911,6 @@ export class InstanceAiService {
reason,
messageGroupId: opts.messageGroupId,
user: opts.user,
snapshotStorage: opts.snapshotStorage,
});
return;
}
@@ -6043,7 +5945,6 @@ export class InstanceAiService {
// message group has to come from the suspended run, not the trace registry.
messageGroupId: opts.messageGroupId,
user: opts.user,
snapshotStorage: opts.snapshotStorage,
});
return;
@@ -6055,8 +5956,7 @@ export class InstanceAiService {
/**
* Terminalizes a run that ended in a stop or a failure. `publishRunFinish` is
* the one step that has to land without it the chat hangs forever so every
* DB-touching step around it is best-effort. `saveAgentTreeSnapshot` rebuilds
* the agent tree by folding the event bus, so it has to come last.
* DB-touching step around it is best-effort.
*/
private async emitTerminalRun(args: {
threadId: string;
@@ -6067,7 +5967,6 @@ export class InstanceAiService {
errorInfo?: RunFinishErrorInfo;
messageGroupId?: string;
user: User;
snapshotStorage: DbSnapshotStorage;
}): Promise<void> {
const { threadId, runId, status } = args;
const context = { threadId, runId };
@@ -6104,12 +6003,6 @@ export class InstanceAiService {
args.user.id,
args.errorInfo,
);
await this.bestEffort(
'Failed to save the agent tree snapshot for a settling run',
context,
async () => await this.saveAgentTreeSnapshot(threadId, runId, args.snapshotStorage),
);
}
private async bestEffort<T>(
@@ -6130,7 +6023,6 @@ export class InstanceAiService {
private spawnBackgroundTask(
runId: string,
opts: SpawnBackgroundTaskOptions,
snapshotStorage: DbSnapshotStorage,
messageGroupIdOverride?: string,
): SpawnBackgroundTaskResult {
const outcome = this.backgroundTasks.spawn({
@@ -6215,13 +6107,6 @@ export class InstanceAiService {
},
onSettled: async (task) => {
await this.terminalOutcome.recordBackgroundTerminalOutcome(task);
await this.saveAgentTreeSnapshot(
opts.threadId,
runId,
snapshotStorage,
true,
task.messageGroupId,
);
// Auto-follow-up: when the last background task finishes and no
// orchestrator run is active, resume the orchestrator so it can
@@ -6536,14 +6421,6 @@ export class InstanceAiService {
suspended.user.id,
);
// Persist the snapshot so the run-finish event (which clears
// in-flight tool calls) is reflected in the stored tree.
await this.saveAgentTreeSnapshot(
suspended.threadId,
suspended.runId,
this.dbSnapshotStorage,
true,
);
await this.tracing.maybeFinalizeRunTraceRoot(suspended.runId, {
status: 'cancelled',
reason,
@@ -6712,7 +6589,6 @@ export class InstanceAiService {
threadId: string,
runId: string,
status: 'completed' | 'cancelled' | 'errored',
snapshotStorage: DbSnapshotStorage,
options?: {
userId?: string;
modelId?: ModelConfig;
@@ -6733,7 +6609,6 @@ export class InstanceAiService {
options?.errorInfo,
);
this.emitRunMetrics(threadId, status, options);
await this.saveAgentTreeSnapshot(threadId, runId, snapshotStorage);
if (status === 'completed' && options?.userId && options?.modelId) {
void this.refineTitleIfNeeded(threadId, options.userId, options.modelId);
}
@@ -6890,70 +6765,6 @@ export class InstanceAiService {
return await this.eventLog.getEventsForRuns(threadId, runIds);
}
/**
* Build an agent tree from in-memory events and persist it as a thread metadata snapshot.
* @param isUpdate If true, updates the existing snapshot for this runId (background task completion).
*/
private async saveAgentTreeSnapshot(
threadId: string,
runId: string,
snapshotStorage: DbSnapshotStorage,
isUpdate = false,
overrideMessageGroupId?: string,
): Promise<void> {
try {
const messageGroupId = overrideMessageGroupId ?? this.runState.getMessageGroupId(threadId);
let events: InstanceAiEvent[];
let groupRunIds: string[] | undefined;
if (messageGroupId) {
groupRunIds = this.getRunIdsForMessageGroup(messageGroupId);
if (groupRunIds.length === 0) {
const snapshot = await snapshotStorage.getLatest(threadId, { messageGroupId, runId });
groupRunIds = snapshot?.runIds?.length ? snapshot.runIds : [runId];
}
events = await this.readRunEvents(threadId, groupRunIds);
} else {
events = await this.readRunEvents(threadId, [runId]);
}
// The tree input comes from the DB, so long runs cannot out-evict their
// own snapshot input (the empty-agentTree bug class). The snapshot write
// itself stays for now so pre-log threads keep rendering; history moves
// to fold-on-read separately.
if (isUpdate && events.length === 0) {
this.logger.warn('Skipped updating empty Instance AI agent tree snapshot', {
threadId,
runId,
messageGroupId,
});
return;
}
const agentTree = buildAgentTreeFromEvents(events);
const tracing = this.tracing.getTraceContext(runId);
const saveOptions = {
messageGroupId,
runIds: groupRunIds,
traceId: tracing?.rootRun.otelTraceId,
spanId: tracing?.rootRun.otelSpanId,
langsmithRunId: tracing?.rootRun.id,
langsmithTraceId: tracing?.rootRun.traceId,
};
if (isUpdate) {
await snapshotStorage.updateLast(threadId, agentTree, runId, saveOptions);
} else {
await snapshotStorage.save(threadId, agentTree, runId, saveOptions);
}
} catch (error) {
this.logger.warn('Failed to save agent tree snapshot', {
threadId,
runId,
error: error instanceof Error ? error.message : String(error),
});
}
}
private parseMcpServers(raw: string): McpServerConfig[] {
if (!raw.trim()) return [];
@@ -1,12 +1,10 @@
import { getRenderHint, normalizeAgentTree } from '@n8n/api-types';
import { normalizeAgentTree } from '@n8n/api-types';
import type {
InstanceAiMessage,
InstanceAiAgentNode,
InstanceAiToolCallState,
InstanceAiTimelineEntry,
} from '@n8n/api-types';
import { orchestratorAgentId } from '@n8n/instance-ai';
import type { AgentDbMessage, AgentTreeSnapshot, MessageContent } from '@n8n/instance-ai';
import type { AgentDbMessage, AgentTreeSnapshot } from '@n8n/instance-ai';
import { z } from 'zod';
import {
@@ -17,44 +15,13 @@ import {
type RunSnapshots = AgentTreeSnapshot[];
const toolCallContentPartSchema = z.object({
type: z.literal('tool-call'),
toolCallId: z.string(),
toolName: z.string(),
input: z.unknown().optional(),
state: z.enum(['pending', 'resolved', 'rejected']).optional(),
output: z.unknown().optional(),
error: z.string().optional(),
});
const textContentPartSchema = z.object({ type: z.literal('text'), text: z.string() });
const reasoningContentPartSchema = z.object({ type: z.literal('reasoning'), text: z.string() });
const opaqueContentPartSchema = z
.object({ type: z.enum(['invalid-tool-call', 'file', 'citation', 'provider']) })
.passthrough();
const contentPartSchema = z.union([
textContentPartSchema,
reasoningContentPartSchema,
toolCallContentPartSchema,
opaqueContentPartSchema,
]);
// ---------------------------------------------------------------------------
// Persisted message shapes
// ---------------------------------------------------------------------------
interface StoredToolInvocation {
state: 'result' | 'call' | 'partial-call';
toolCallId: string;
toolName: string;
args: Record<string, unknown>;
result?: unknown;
error?: string;
}
type StoredContentPart = MessageContent;
export interface StoredAgentMessage {
id: string;
role: string;
@@ -104,187 +71,10 @@ function extractReasoningFromParts(parts: unknown[]): string {
.join('');
}
function extractParts(content: unknown): StoredContentPart[] | undefined {
if (Array.isArray(content)) return content.filter(isStoredContentPart);
return undefined;
}
function isStoredContentPart(value: unknown): value is StoredContentPart {
return contentPartSchema.safeParse(value).success;
}
function toRecord(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
}
function nativeToolPartToInvocation(part: StoredContentPart): StoredToolInvocation | undefined {
if (part.type !== 'tool-call') return undefined;
const parsed = toolCallContentPartSchema.safeParse(part);
if (!parsed.success) return undefined;
const toolCall = parsed.data;
const args = toRecord(toolCall.input);
if (toolCall.state === 'resolved') {
return {
state: 'result',
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
args,
result: toolCall.output,
};
}
if (toolCall.state === 'rejected') {
return {
state: 'result',
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
args,
error: toolCall.error,
};
}
return {
state: 'call',
toolCallId: toolCall.toolCallId,
toolName: toolCall.toolName,
args,
};
}
function extractToolInvocations(content: unknown): StoredToolInvocation[] {
if (typeof content === 'string') return [];
if (Array.isArray(content))
return content.filter(isStoredContentPart).flatMap((part) => {
const invocation = nativeToolPartToInvocation(part);
return invocation ? [invocation] : [];
});
return [];
}
/**
* Coarse per-row timing for reconstructed tool calls: the interval from the
* previous stored message to this one brackets everything the response did.
* Real per-call timestamps only exist in run snapshots; this approximation
* lets a snapshot-less reload still show "Thought for Xs" (derived from
* min-start/max-end across a thinking block) instead of a bare fallback.
* Known coarseness: every call in a row shares the bracket, and an HITL pause
* between rows counts as thinking time.
*/
interface RowTiming {
startedAt: string;
completedAt: string;
}
function buildToolCallState(
invocation: StoredToolInvocation,
timing?: RowTiming,
): InstanceAiToolCallState {
const isCompleted = invocation.state === 'result';
return {
toolCallId: invocation.toolCallId,
toolName: invocation.toolName,
args: invocation.args,
result: isCompleted ? invocation.result : undefined,
error: isCompleted ? invocation.error : undefined,
isLoading: !isCompleted,
renderHint: getRenderHint(invocation.toolName),
...(timing ? { startedAt: timing.startedAt } : {}),
...(timing && isCompleted ? { completedAt: timing.completedAt } : {}),
};
}
/**
* Build a chronological timeline from native parts (preserves reasoning vs
* tool-call vs text ordering). Falls back to a reasoning-first,
* tool-calls-next heuristic when parts aren't available.
*
* `responseId` is a synthetic per-message id: each stored assistant row is one
* LLM response, and the frontend needs response grouping to tell intermediate
* narration (trace content follows in the same response) from final answers.
* Without it, a reconstructed timeline renders every narration text outside
* the thinking blocks, splitting them.
*/
function buildTimeline(
textContent: string,
reasoning: string,
toolCalls: InstanceAiToolCallState[],
parts?: StoredContentPart[],
responseId?: string,
): InstanceAiTimelineEntry[] {
const responseRef = responseId ? { responseId } : {};
// If parts are available, use their ordering (chronologically accurate)
if (parts?.length) {
const timeline: InstanceAiTimelineEntry[] = [];
for (const part of parts) {
if (part.type === 'text' && part.text) {
timeline.push({ type: 'text', content: part.text, ...responseRef });
} else if (part.type === 'reasoning' && part.text) {
timeline.push({ type: 'reasoning', content: part.text, ...responseRef });
} else if (part.type === 'tool-call' && part.toolCallId) {
timeline.push({ type: 'tool-call', toolCallId: part.toolCallId, ...responseRef });
}
}
return timeline;
}
// No parts — heuristic: reasoning first, then tool calls, then text
// (most common agent pattern)
const timeline: InstanceAiTimelineEntry[] = [];
if (reasoning) {
timeline.push({ type: 'reasoning', content: reasoning, ...responseRef });
}
for (const tc of toolCalls) {
timeline.push({ type: 'tool-call', toolCallId: tc.toolCallId, ...responseRef });
}
if (textContent) {
timeline.push({ type: 'text', content: textContent, ...responseRef });
}
return timeline;
}
/**
* Build a flat agent tree (orchestrator only) from tool invocations.
* Used when no snapshot is available, or when falling back from a degenerate one.
* `status` is inherited from the snapshot so a `cancelled` run still reads as cancelled.
*
* A reconstructed tree is always historical there is no live stream feeding it so a
* non-terminal status is normalized to `completed` (a mid-run snapshot must not render as
* busy forever), and any tool call left loading on a stopped run is settled, mirroring the
* live `run-finish` reducer so a cancelled bubble doesn't show a spinner that never resolves.
*/
function buildFlatAgentTree(
runId: string,
textContent: string,
reasoning: string,
toolCalls: InstanceAiToolCallState[],
parts?: StoredContentPart[],
status: InstanceAiAgentNode['status'] = 'completed',
responseId?: string,
): InstanceAiAgentNode {
const resolvedStatus = status === 'active' ? 'completed' : status;
const settledToolCalls =
resolvedStatus === 'cancelled' || resolvedStatus === 'error'
? toolCalls.map((tc) => (tc.isLoading ? { ...tc, isLoading: false } : tc))
: toolCalls;
return {
agentId: orchestratorAgentId(runId),
role: 'orchestrator',
status: resolvedStatus,
textContent,
reasoning,
toolCalls: settledToolCalls,
children: [],
timeline: buildTimeline(textContent, reasoning, settledToolCalls, parts, responseId),
};
}
/**
* Whether a snapshot tree carries anything worth rendering. An empty terminal tree
* e.g. a `cancelled` run whose events were lost before the
* snapshot was built has none of these, so the message-derived flat tree is preferred.
* snapshot was built has none of these, so the message renders without a tree.
*/
function isRenderableTree(tree: InstanceAiAgentNode): boolean {
return (
@@ -302,25 +92,6 @@ function isRenderableTree(tree: InstanceAiAgentNode): boolean {
);
}
/**
* Merge an earlier flat orchestrator tree into a later one (earlier content first).
* Aggregates the assistant rows of a turn whose snapshot is empty so the whole turn's
* orchestrator activity renders as one bubble after the dedup collapse, instead of
* keeping only the last row.
*/
function mergeFlatAgentTrees(
earlier: InstanceAiAgentNode,
later: InstanceAiAgentNode,
): InstanceAiAgentNode {
return {
...later,
textContent: [earlier.textContent, later.textContent].filter(Boolean).join('\n\n'),
reasoning: [earlier.reasoning, later.reasoning].filter(Boolean).join('\n\n'),
toolCalls: [...earlier.toolCalls, ...later.toolCalls],
timeline: [...earlier.timeline, ...later.timeline],
};
}
function snapshotTimestamp(snapshot: AgentTreeSnapshot): string {
return (snapshot.updatedAt ?? snapshot.createdAt ?? new Date(0)).toISOString();
}
@@ -364,16 +135,11 @@ function buildSnapshotMessage(snapshot: AgentTreeSnapshot): InstanceAiMessage {
// Main parser
// ---------------------------------------------------------------------------
/**
* Durable-log instrumentation: counts assistant messages that rendered from
* the message-derived fallback ladder instead of a renderable snapshot tree.
* Forwarded to the metrics pipeline via DurableLogMetrics.notifyParserFallbacks.
*/
export const messageParserStats = { fallbackActivations: 0 };
/**
* Converts persisted native agent messages into rich InstanceAiMessage objects
* with agent trees (from snapshots or reconstructed flat trees).
* with agent trees folded from the durable event log. A message whose run left
* no log rows (eval-seeded threads, pre-log dev instances) renders from its
* `content`/`reasoning` fields without a tree.
*/
export function parseStoredMessages(
storedMessages: Array<AgentDbMessage | StoredAgentMessage>,
@@ -390,18 +156,16 @@ export function parseStoredMessages(
// orphan snapshots before, between, or after assistant rows.
let nextSnapshotIdx = 0;
const consumedSnapshots = new Set<AgentTreeSnapshot>();
// Messages whose `agentTree` originated from a snapshot (as opposed to
// being synthesized by `buildFlatAgentTree`). Used by the dedupe pass to
// prefer transferring snapshot trees forward in the in-flight HITL case.
// Messages whose `agentTree` is a renderable snapshot tree. Used by the
// dedupe pass to transfer snapshot trees forward in the in-flight HITL case.
const messagesWithSnapshotTree = new Set<InstanceAiMessage>();
let lastUserMessageId: string | undefined;
function pushSnapshotMessage(snapshot: AgentTreeSnapshot): void {
const built = buildSnapshotMessage(snapshot);
// A degenerate (empty) orphan snapshot must not count as authoritative: when the
// turn also has message rows, their reconstructed flat tree must win the dedup
// collapse instead of this empty tree clobbering it. Mirrors the paired-row guard.
// A degenerate (empty) orphan snapshot must not count as authoritative in
// the dedup collapse. Mirrors the paired-row guard.
if (isRenderableTree(snapshot.tree)) messagesWithSnapshotTree.add(built);
messages.push(built);
}
@@ -476,52 +240,17 @@ export function parseStoredMessages(
if (msg.role === 'assistant') {
const reasoning = extractReasoningFromContent(msg.content);
const invocations = extractToolInvocations(msg.content);
const prevMessage = messageIndex > 0 ? conversationMessages[messageIndex - 1] : undefined;
const timing: RowTiming | undefined = prevMessage
? {
startedAt: prevMessage.createdAt.toISOString(),
completedAt: msg.createdAt.toISOString(),
}
: undefined;
const toolCalls = invocations.map((invocation) => buildToolCallState(invocation, timing));
const parts = extractParts(msg.content);
const snapshot = takeSnapshotForAssistant(msg, messageIndex);
// Use the native runId from the snapshot (matches SSE events),
// falling back to the user-message ID if no snapshot exists.
const runId = snapshot?.runId ?? lastUserMessageId ?? msg.id;
// The message id doubles as the synthetic responseId: one stored
// assistant row = one LLM response, and unlike `runId` (which falls
// back to the user-message id shared by the whole turn) it is unique
// per row, so response grouping survives the flat-tree merge.
const messageFlatTree =
toolCalls.length > 0 || text || reasoning
? buildFlatAgentTree(
runId,
text,
reasoning,
toolCalls,
parts,
snapshot?.tree.status,
msg.id,
)
: undefined;
// Carry the cancellation cause onto the fallback tree so a stopped run is
// still attributable (user/timeout/shutdown) after the snapshot was lost.
if (messageFlatTree && snapshot?.tree.cancellationReason) {
messageFlatTree.cancellationReason = snapshot.tree.cancellationReason;
}
// Prefer the snapshot tree, but when it carries no renderable content (e.g. an
// empty `cancelled` tree from a run whose events were lost before the snapshot
// was built) fall back to the message-derived flat tree so the turn's work still
// renders on reload. A non-renderable snapshot is never authoritative — if there
// is no flat tree either, leave the tree undefined rather than re-admitting the
// empty one.
// A non-renderable tree (e.g. an empty `cancelled` tree from a run whose
// events were lost) is never authoritative — leave the tree undefined and
// let the message render from its own content instead.
const snapshotIsRenderable = snapshot !== undefined && isRenderableTree(snapshot.tree);
const agentTree = snapshotIsRenderable ? snapshot.tree : messageFlatTree;
if (!snapshotIsRenderable && messageFlatTree) messageParserStats.fallbackActivations++;
const agentTree = snapshotIsRenderable ? snapshot.tree : undefined;
const assistantMessage: InstanceAiMessage = {
id: msg.id,
@@ -570,10 +299,9 @@ export function parseStoredMessages(
// Follow-up runs in the same group produce separate DB rows; keep only
// the latest (which carries the full runIds array and complete tree).
//
// In-flight HITL turns are different: the snapshot is paired with a
// *middle* checkpoint message via timestamp matching, and the latest
// message in the turn has only an auto-generated flat tree from
// `buildFlatAgentTree`. Keeping just the latest would drop the
// In-flight HITL turns are different: the snapshot can pair with a
// *middle* row of the turn via timestamp matching, leaving the latest
// message without a tree. Keeping just the latest would drop the
// snapshot's tree (including its live confirmation cards), so transfer
// the snapshot's `agentTree` + `runIds` onto the kept message when the
// kept one's tree didn't come from a snapshot.
@@ -589,19 +317,10 @@ export function parseStoredMessages(
}
const kept = messages[keptIdx];
const candidate = messages[i];
if (!messagesWithSnapshotTree.has(kept)) {
if (messagesWithSnapshotTree.has(candidate)) {
kept.agentTree = candidate.agentTree;
kept.runIds = candidate.runIds;
messagesWithSnapshotTree.add(kept);
} else if (candidate.agentTree) {
// Neither row is snapshot-backed (degenerate-snapshot turn): aggregate the
// earlier row's flat-tree activity into the kept bubble so the whole turn's
// orchestrator work survives the collapse instead of just the last row.
kept.agentTree = kept.agentTree
? mergeFlatAgentTrees(candidate.agentTree, kept.agentTree)
: candidate.agentTree;
}
if (!messagesWithSnapshotTree.has(kept) && messagesWithSnapshotTree.has(candidate)) {
kept.agentTree = candidate.agentTree;
kept.runIds = candidate.runIds;
messagesWithSnapshotTree.add(kept);
}
toRemove.add(i);
}
@@ -88,6 +88,80 @@ describe('InstanceAiEventLogRepository', () => {
});
});
describe('findLangsmithAnchor', () => {
const runStartRow = (
seq: number,
runId: string,
payload: Record<string, unknown>,
): InstanceAiEventLogEntry =>
({
seq,
runId,
createdAt: new Date(`2026-07-01T10:00:0${seq}.000Z`),
payload: JSON.stringify({ type: 'run-start', runId, agentId: 'a1', payload }),
}) as InstanceAiEventLogEntry;
const repoWithRunStarts = (rows: InstanceAiEventLogEntry[]) => {
const repo = Object.create(
InstanceAiEventLogRepository.prototype,
) as InstanceAiEventLogRepository;
const find = vi.fn().mockResolvedValue(rows);
Object.defineProperty(repo, 'find', { value: find, configurable: true });
return { repo, find };
};
it('resolves the group anchor from a later sibling when earlier ones carry no ids', async () => {
const { repo, find } = repoWithRunStarts([
// Anchored, but a different group: must never hijack another group's turn.
runStartRow(1, 'run-0', {
messageGroupId: 'mg-other',
langsmithRunId: 'ls-other',
langsmithTraceId: 'trace-other',
}),
// The group's first sibling, unanchored — what a segment without
// tracing leaves behind.
runStartRow(2, 'run-1', { messageGroupId: 'mg-1' }),
runStartRow(3, 'run-2', {
messageGroupId: 'mg-1',
langsmithRunId: 'ls-run',
langsmithTraceId: 'ls-trace',
}),
]);
await expect(repo.findLangsmithAnchor('thread-1', 'mg-1')).resolves.toEqual({
langsmithRunId: 'ls-run',
langsmithTraceId: 'ls-trace',
});
// One row per run, not the whole log.
expect(find).toHaveBeenCalledWith(
expect.objectContaining({ where: { threadId: 'thread-1', type: 'run-start' } }),
);
});
it('falls back to the runId for a turn with no message group', async () => {
const { repo } = repoWithRunStarts([
runStartRow(1, 'run-1', { langsmithRunId: 'ls-run', langsmithTraceId: 'ls-trace' }),
]);
await expect(repo.findLangsmithAnchor('thread-1', 'run-1')).resolves.toEqual({
langsmithRunId: 'ls-run',
langsmithTraceId: 'ls-trace',
});
});
it('resolves undefined for a genuinely untraced turn', async () => {
const { repo } = repoWithRunStarts([
runStartRow(1, 'run-1', { messageGroupId: 'mg-1' }),
runStartRow(2, 'run-2', { messageGroupId: 'mg-1' }),
]);
// No sibling in the group is anchored…
await expect(repo.findLangsmithAnchor('thread-1', 'mg-1')).resolves.toBeUndefined();
// …and the runId fallback must not fabricate an anchor from an id-less start.
await expect(repo.findLangsmithAnchor('thread-1', 'run-2')).resolves.toBeUndefined();
});
});
describe('findRunIdsInWindow', () => {
it('bounds the window half-open so the next page owns its first fact', async () => {
const repo = Object.create(
@@ -1,68 +0,0 @@
import type { FindManyOptions, FindOperator } from '@n8n/typeorm';
import type { InstanceAiRunSnapshot } from '../../entities/instance-ai-run-snapshot.entity';
import { InstanceAiRunSnapshotRepository } from '../instance-ai-run-snapshot.repository';
/** The `createdAt` operator `findInWindow` built, as {type, value}. */
function createdAtOperator(find: ReturnType<typeof vi.fn>) {
const options = find.mock.calls[0][0] as FindManyOptions<InstanceAiRunSnapshot>;
const where = options.where as { createdAt?: FindOperator<Date> };
const operator = where.createdAt;
if (!operator) return undefined;
return { type: operator.type, value: operator.value };
}
describe('InstanceAiRunSnapshotRepository.findInWindow', () => {
const since = new Date('2026-01-01T00:00:00.000Z');
const before = new Date('2026-01-02T00:00:00.000Z');
function createRepo() {
const repo = Object.create(
InstanceAiRunSnapshotRepository.prototype,
) as InstanceAiRunSnapshotRepository;
const find = vi.fn().mockResolvedValue([]);
Object.defineProperty(repo, 'find', { value: find, configurable: true });
return { repo, find };
}
it('bounds both sides when the window is closed, upper bound exclusive', async () => {
// Half-open: the next page owns a snapshot written exactly at the
// boundary, so no snapshot is claimed by two pages.
const { repo, find } = createRepo();
await repo.findInWindow('thread-1', { since, before });
expect(createdAtOperator(find)).toEqual({
type: 'and',
value: [
expect.objectContaining({ type: 'moreThanOrEqual', value: since }),
expect.objectContaining({ type: 'lessThan', value: before }),
],
});
});
it('bounds only the lower side for the newest page', async () => {
const { repo, find } = createRepo();
await repo.findInWindow('thread-1', { since });
expect(createdAtOperator(find)).toEqual({ type: 'moreThanOrEqual', value: since });
});
it('bounds only the upper side when there is no lower bound', async () => {
const { repo, find } = createRepo();
await repo.findInWindow('thread-1', { before });
expect(createdAtOperator(find)).toEqual({ type: 'lessThan', value: before });
});
it('reads the whole thread for an open window', async () => {
const { repo, find } = createRepo();
await repo.findInWindow('thread-1', {});
expect(createdAtOperator(find)).toBeUndefined();
expect(find).toHaveBeenCalledWith(expect.objectContaining({ where: { threadId: 'thread-1' } }));
});
});
@@ -1,7 +1,6 @@
export { InstanceAiThreadRepository } from './instance-ai-thread.repository';
export { InstanceAiMessageRepository } from './instance-ai-message.repository';
export { InstanceAiResourceRepository } from './instance-ai-resource.repository';
export { InstanceAiRunSnapshotRepository } from './instance-ai-run-snapshot.repository';
export { InstanceAiIterationLogRepository } from './instance-ai-iteration-log.repository';
export { InstanceAiCheckpointRepository } from './instance-ai-checkpoint.repository';
export { InstanceAiObservationRepository } from './instance-ai-observation.repository';
@@ -111,8 +111,7 @@ export class InstanceAiEventLogRepository extends Repository<InstanceAiEventLogE
*
* Selects no `payload`, so it does not pay the JSON cost of the rows it
* scans. The scan itself is bounded by the thread (PK-led); if it ever shows
* up in a profile, a `(threadId, createdAt)` index is the next lever
* `instance_ai_run_snapshots` already carries the equivalent one.
* up in a profile, a `(threadId, createdAt)` index is the next lever.
*/
async findRunIdsInWindow(
threadId: string,
@@ -148,6 +147,40 @@ export class InstanceAiEventLogRepository extends Repository<InstanceAiEventLogE
});
}
/**
* Resolve the LangSmith root-run anchor for a responseId (UI sends
* `messageGroupId ?? runId`). The ids ride on the run-start fact; prefer the
* earliest run-start in the message group THAT CARRIES the ids, falling back
* to the run whose id matches. Sibling runs of one turn share the
* `message_turn` root, but not every sibling's run-start is anchored a
* segment without tracing leaves the ids to a later one mirroring the
* snapshot store, which kept the group's first non-null ids. Runs recorded
* before the anchor rode on run-start (the snapshot table carried it and
* dropped without a copy), and genuinely untraced runs, resolve undefined.
*/
async findLangsmithAnchor(
threadId: string,
responseId: string,
): Promise<{ langsmithRunId: string; langsmithTraceId: string } | undefined> {
const rows = await this.find({
where: { threadId, type: 'run-start' },
order: { seq: 'ASC' },
});
const starts = rows.map((r) => this.toEvent(r));
const isAnchoredRunStart = (
e: InstanceAiEvent,
): e is Extract<InstanceAiEvent, { type: 'run-start' }> =>
e.type === 'run-start' && Boolean(e.payload.langsmithRunId && e.payload.langsmithTraceId);
const byGroup = starts.find(
(e) => isAnchoredRunStart(e) && e.payload.messageGroupId === responseId,
);
const anchor = byGroup ?? starts.find((e) => e.runId === responseId);
if (anchor?.type !== 'run-start') return undefined;
const { langsmithRunId, langsmithTraceId } = anchor.payload;
if (!langsmithRunId || !langsmithTraceId) return undefined;
return { langsmithRunId, langsmithTraceId };
}
/** Timestamp of the run's most recent durable fact (sweep liveness proxy). */
async lastFactAt(threadId: string, runId: string): Promise<Date | null> {
const row = await this.createQueryBuilder('e')
@@ -1,36 +0,0 @@
import { Service } from '@n8n/di';
import { And, DataSource, LessThan, MoreThanOrEqual, Repository } from '@n8n/typeorm';
import { InstanceAiRunSnapshot } from '../entities/instance-ai-run-snapshot.entity';
@Service()
export class InstanceAiRunSnapshotRepository extends Repository<InstanceAiRunSnapshot> {
constructor(dataSource: DataSource) {
super(InstanceAiRunSnapshot, dataSource.manager);
}
/**
* Snapshots written inside the half-open window `[since, before)`, oldest
* first; an open bound means unbounded on that side. `(threadId, createdAt)`
* is indexed, so this is a range scan and the `tree` column is only read for
* the rows in the window.
*/
async findInWindow(
threadId: string,
window: { since?: Date; before?: Date },
): Promise<InstanceAiRunSnapshot[]> {
const { since, before } = window;
const createdAt =
since && before
? And(MoreThanOrEqual(since), LessThan(before))
: since
? MoreThanOrEqual(since)
: before
? LessThan(before)
: undefined;
return await this.find({
where: { threadId, ...(createdAt ? { createdAt } : {}) },
order: { createdAt: 'ASC' },
});
}
}
@@ -1,180 +0,0 @@
import { mock } from 'vitest-mock-extended';
import type { InstanceAiRunSnapshot } from '../../entities/instance-ai-run-snapshot.entity';
import type { InstanceAiRunSnapshotRepository } from '../../repositories/instance-ai-run-snapshot.repository';
import { DbSnapshotStorage } from '../db-snapshot-storage';
function makeRow(overrides: Partial<InstanceAiRunSnapshot> = {}): InstanceAiRunSnapshot {
return {
threadId: 'thread-1',
runId: 'run-1',
messageGroupId: null,
runIds: null,
tree: JSON.stringify({ agentId: 'agent-root' }),
traceId: null,
spanId: null,
langsmithRunId: null,
langsmithTraceId: null,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
} as InstanceAiRunSnapshot;
}
describe('DbSnapshotStorage', () => {
const repo = mock<InstanceAiRunSnapshotRepository>();
const storage = new DbSnapshotStorage(repo);
beforeEach(() => {
vi.clearAllMocks();
});
describe('findLangsmithAnchor', () => {
it('returns anchor when messageGroupId matches a row with IDs', async () => {
repo.findOne.mockResolvedValueOnce(
makeRow({
messageGroupId: 'mg-1',
langsmithRunId: 'ls-run-1',
langsmithTraceId: 'ls-trace-1',
}),
);
const anchor = await storage.findLangsmithAnchor('thread-1', 'mg-1');
expect(anchor).toEqual({ langsmithRunId: 'ls-run-1', langsmithTraceId: 'ls-trace-1' });
expect(repo.findOne).toHaveBeenCalledWith({
where: { threadId: 'thread-1', messageGroupId: 'mg-1' },
order: { createdAt: 'ASC' },
});
});
it('falls back to runId lookup when messageGroupId lookup misses', async () => {
repo.findOne.mockResolvedValueOnce(null);
repo.findOneBy.mockResolvedValueOnce(
makeRow({
runId: 'mg-1',
langsmithRunId: 'ls-run-2',
langsmithTraceId: 'ls-trace-2',
}),
);
const anchor = await storage.findLangsmithAnchor('thread-1', 'mg-1');
expect(anchor).toEqual({ langsmithRunId: 'ls-run-2', langsmithTraceId: 'ls-trace-2' });
expect(repo.findOneBy).toHaveBeenCalledWith({ threadId: 'thread-1', runId: 'mg-1' });
});
it('returns undefined when row is found but langsmith IDs are null', async () => {
repo.findOne.mockResolvedValueOnce(makeRow({ messageGroupId: 'mg-1' }));
const anchor = await storage.findLangsmithAnchor('thread-1', 'mg-1');
expect(anchor).toBeUndefined();
});
it('returns undefined when no row exists at all', async () => {
repo.findOne.mockResolvedValueOnce(null);
repo.findOneBy.mockResolvedValueOnce(null);
const anchor = await storage.findLangsmithAnchor('thread-1', 'missing');
expect(anchor).toBeUndefined();
});
});
describe('save', () => {
it('persists trace IDs via upsert', async () => {
await storage.save('thread-1', { agentId: 'agent-root' } as never, 'run-1', {
messageGroupId: 'mg-1',
runIds: ['run-1'],
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
langsmithRunId: 'ls-run-1',
langsmithTraceId: 'ls-trace-1',
});
expect(repo.upsert).toHaveBeenCalledWith(
expect.objectContaining({
threadId: 'thread-1',
runId: 'run-1',
messageGroupId: 'mg-1',
runIds: ['run-1'],
traceId: '0123456789abcdef0123456789abcdef',
spanId: '0123456789abcdef',
langsmithRunId: 'ls-run-1',
langsmithTraceId: 'ls-trace-1',
}),
['threadId', 'runId'],
);
});
it('writes nulls when trace IDs are absent', async () => {
await storage.save('thread-1', { agentId: 'agent-root' } as never, 'run-1');
expect(repo.upsert).toHaveBeenCalledWith(
expect.objectContaining({
traceId: null,
spanId: null,
langsmithRunId: null,
langsmithTraceId: null,
}),
expect.anything(),
);
});
});
describe('updateLast', () => {
it('preserves existing trace IDs when the caller does not supply new ones', async () => {
const existing = makeRow({
messageGroupId: 'mg-1',
traceId: 'existing-trace',
spanId: 'existing-span',
langsmithRunId: 'ls-run-existing',
langsmithTraceId: 'ls-trace-existing',
});
repo.findOne.mockResolvedValueOnce(existing);
await storage.updateLast('thread-1', { agentId: 'updated' } as never, 'run-1', {
messageGroupId: 'mg-1',
});
expect(repo.update).toHaveBeenCalledWith(
{ threadId: 'thread-1', runId: 'run-1' },
expect.objectContaining({
traceId: 'existing-trace',
spanId: 'existing-span',
langsmithRunId: 'ls-run-existing',
langsmithTraceId: 'ls-trace-existing',
}),
);
});
});
describe('getForWindow', () => {
it('passes the window through and hydrates the rows it gets back', async () => {
const since = new Date('2026-01-01T00:00:00.000Z');
repo.findInWindow.mockResolvedValueOnce([
makeRow({ runId: 'run-1', messageGroupId: 'mg-1', createdAt: since }),
]);
const snapshots = await storage.getForWindow('thread-1', { since });
expect(repo.findInWindow).toHaveBeenCalledWith('thread-1', { since });
expect(snapshots).toEqual([
expect.objectContaining({
runId: 'run-1',
messageGroupId: 'mg-1',
tree: { agentId: 'agent-root' },
}),
]);
});
it('defaults to the whole thread when no window is given', async () => {
repo.findInWindow.mockResolvedValueOnce([]);
await storage.getForWindow('thread-1');
expect(repo.findInWindow).toHaveBeenCalledWith('thread-1', {});
});
});
});
@@ -1,221 +0,0 @@
import type { InstanceAiAgentNode } from '@n8n/api-types';
import { Service } from '@n8n/di';
import type { AgentTreeSnapshot } from '@n8n/instance-ai';
import { jsonParse } from 'n8n-workflow';
import { InstanceAiRunSnapshotRepository } from '../repositories/instance-ai-run-snapshot.repository';
/**
* Walk a saved agent tree and flip everything in-flight to a terminal state.
* Active sub-agents become `cancelled`, loading tool calls stop loading, and
* unresolved HITL confirmation cards get a `denied` status so the frontend
* stops rendering Allow / Request-changes buttons. Mutates `node` in place.
*/
function cancelInFlightNodes(node: InstanceAiAgentNode): void {
if (node.status === 'active') node.status = 'cancelled';
for (const call of node.toolCalls) {
if (!call.isLoading) continue;
call.isLoading = false;
if (call.confirmation && !call.confirmationStatus) {
call.confirmationStatus = 'denied';
}
}
for (const child of node.children) cancelInFlightNodes(child);
}
export interface SaveSnapshotOptions {
messageGroupId?: string;
runIds?: string[];
traceId?: string;
spanId?: string;
langsmithRunId?: string;
langsmithTraceId?: string;
}
@Service()
export class DbSnapshotStorage {
constructor(private readonly repo: InstanceAiRunSnapshotRepository) {}
async getLatest(
threadId: string,
options: { messageGroupId?: string; runId?: string } = {},
): Promise<AgentTreeSnapshot | undefined> {
const { messageGroupId, runId } = options;
const row = messageGroupId
? await this.repo.findOne({
where: { threadId, messageGroupId },
order: { createdAt: 'DESC' },
})
: runId
? await this.repo.findOne({
where: { threadId, runId },
order: { createdAt: 'DESC' },
})
: await this.repo.findOne({
where: { threadId },
order: { createdAt: 'DESC' },
});
if (!row) return undefined;
return {
tree: jsonParse<InstanceAiAgentNode>(row.tree),
runId: row.runId,
messageGroupId: row.messageGroupId ?? undefined,
runIds: row.runIds ?? undefined,
traceId: row.traceId ?? undefined,
spanId: row.spanId ?? undefined,
langsmithRunId: row.langsmithRunId ?? undefined,
langsmithTraceId: row.langsmithTraceId ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
async save(
threadId: string,
agentTree: InstanceAiAgentNode,
runId: string,
options: SaveSnapshotOptions = {},
): Promise<void> {
const { messageGroupId, runIds, traceId, spanId, langsmithRunId, langsmithTraceId } = options;
await this.repo.upsert(
{
threadId,
runId,
messageGroupId: messageGroupId ?? null,
runIds: runIds ?? null,
tree: JSON.stringify(agentTree),
traceId: traceId ?? null,
spanId: spanId ?? null,
langsmithRunId: langsmithRunId ?? null,
langsmithTraceId: langsmithTraceId ?? null,
},
['threadId', 'runId'],
);
}
async updateLast(
threadId: string,
agentTree: InstanceAiAgentNode,
runId: string,
options: SaveSnapshotOptions = {},
): Promise<void> {
const { messageGroupId, runIds, traceId, spanId, langsmithRunId, langsmithTraceId } = options;
// Prefer lookup by messageGroupId when available
if (messageGroupId) {
const existing = await this.repo.findOne({
where: { threadId, messageGroupId },
order: { createdAt: 'DESC' },
});
if (existing) {
await this.repo.update(
{ threadId: existing.threadId, runId: existing.runId },
{
runId,
tree: JSON.stringify(agentTree),
messageGroupId,
runIds: runIds ?? existing.runIds,
// Preserve existing trace IDs if caller didn't provide new ones.
traceId: traceId ?? existing.traceId,
spanId: spanId ?? existing.spanId,
langsmithRunId: langsmithRunId ?? existing.langsmithRunId,
langsmithTraceId: langsmithTraceId ?? existing.langsmithTraceId,
},
);
return;
}
}
// Fall back to runId lookup
const byRunId = await this.repo.findOneBy({ threadId, runId });
if (byRunId) {
await this.repo.update(
{ threadId, runId },
{
tree: JSON.stringify(agentTree),
messageGroupId: messageGroupId ?? byRunId.messageGroupId,
runIds: runIds ?? byRunId.runIds,
traceId: traceId ?? byRunId.traceId,
spanId: spanId ?? byRunId.spanId,
langsmithRunId: langsmithRunId ?? byRunId.langsmithRunId,
langsmithTraceId: langsmithTraceId ?? byRunId.langsmithTraceId,
},
);
return;
}
// No existing row — insert
await this.save(threadId, agentTree, runId, options);
}
/**
* Mark an existing snapshot as a cancelled run, terminalising every
* `active` node and every in-flight tool call (including unresolved HITL
* confirmations) in the saved tree. Used when a run is being marked
* terminal after the in-memory event bus is gone e.g. handling a
* confirmation orphaned by a restart because rebuilding the tree from
* an empty bus would clobber the saved plan / ask card with an empty
* cancelled tree. Keeps the tool calls and confirmation payload intact
* so the user can still see what was being planned, just
* without interactive buttons.
*/
async markRunCancelled(threadId: string, runId: string): Promise<void> {
const key = { threadId, runId };
const row = await this.repo.findOneBy(key);
if (!row) return;
const tree = jsonParse<InstanceAiAgentNode>(row.tree);
cancelInFlightNodes(tree);
await this.repo.update(key, { tree: JSON.stringify(tree) });
}
/**
* Snapshots written inside the half-open window `[since, before)`, oldest
* first. The window is the span of the message page being rendered: a
* snapshot outside it has no message to pair with, and `parseStoredMessages`
* would surface it as a message of its own. Passing `{}` reads the whole
* thread.
*
* `(threadId, createdAt)` is indexed, so the bounded read is a range scan
* and the `tree` column is only parsed for the rows the page needs.
*/
async getForWindow(
threadId: string,
window: { since?: Date; before?: Date } = {},
): Promise<AgentTreeSnapshot[]> {
const rows = await this.repo.findInWindow(threadId, window);
return rows.map((r) => ({
tree: jsonParse<InstanceAiAgentNode>(r.tree),
runId: r.runId,
messageGroupId: r.messageGroupId ?? undefined,
runIds: r.runIds ?? undefined,
traceId: r.traceId ?? undefined,
spanId: r.spanId ?? undefined,
langsmithRunId: r.langsmithRunId ?? undefined,
langsmithTraceId: r.langsmithTraceId ?? undefined,
createdAt: r.createdAt,
updatedAt: r.updatedAt,
}));
}
/**
* Resolve the LangSmith root-run anchor for a given responseId
* (UI sends `messageGroupId ?? runId`). Prefers the earliest snapshot row
* in a message group so feedback attaches to the `message_turn` root run.
*/
async findLangsmithAnchor(
threadId: string,
responseId: string,
): Promise<{ langsmithRunId: string; langsmithTraceId: string } | undefined> {
const byGroup = await this.repo.findOne({
where: { threadId, messageGroupId: responseId },
order: { createdAt: 'ASC' },
});
const row = byGroup ?? (await this.repo.findOneBy({ threadId, runId: responseId }));
if (!row?.langsmithRunId || !row.langsmithTraceId) return undefined;
return { langsmithRunId: row.langsmithRunId, langsmithTraceId: row.langsmithTraceId };
}
}
@@ -1,4 +1,3 @@
export { DbSnapshotStorage } from './db-snapshot-storage';
export { DbIterationLogStorage } from './db-iteration-log-storage';
export { TypeORMAgentCheckpointStore } from './typeorm-agent-checkpoint-store';
export { TypeORMAgentMemory } from './typeorm-agent-memory';
@@ -365,7 +365,7 @@ export class TypeORMAgentMemory
/**
* Delete every thread owned by `resourceId` (a user), the sub-agent threads
* spawned under those threads, and all of their working-memory resources.
* Downstream rows (messages, checkpoints, run snapshots, iteration logs,
* Downstream rows (messages, checkpoints, event-log entries, iteration logs,
* grants, pending confirmations, observations) cascade via their `threadId`
* FK; resources have no FK to threads and are removed explicitly. Returns the
* number of owner threads deleted.
@@ -12,7 +12,6 @@ import { UserError } from 'n8n-workflow';
import type { InstanceAiPendingConfirmation } from './entities/instance-ai-pending-confirmation.entity';
import type { InProcessEventBus } from './event-bus/in-process-event-bus';
import type { InstanceAiPendingConfirmationRepository } from './repositories/instance-ai-pending-confirmation.repository';
import type { DbSnapshotStorage } from './storage/db-snapshot-storage';
function getErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
@@ -66,9 +65,6 @@ export type OrphanConfirmationStore = Pick<InstanceAiPendingConfirmationReposito
/** The slice of the run-state registry the restorer reads and writes. */
export type SuspendedRunStateRegistry = Pick<RunStateRegistry<User>, 'suspendRun' | 'hasLiveRun'>;
/** The slice of snapshot storage the restorer uses to terminalise a snapshot. */
export type RunSnapshotCanceller = Pick<DbSnapshotStorage, 'markRunCancelled'>;
/** The slice of the event bus the restorer uses to drop a stale client card. */
export type RunFinishEventPublisher = Pick<InProcessEventBus, 'publish'>;
@@ -76,7 +72,6 @@ export interface SuspendedRunRestorerOptions {
logger: Logger;
pendingConfirmationRepo: OrphanConfirmationStore;
runState: SuspendedRunStateRegistry;
dbSnapshotStorage: RunSnapshotCanceller;
eventBus: RunFinishEventPublisher;
rebuilder: SuspendedRunRebuilder;
}
@@ -101,8 +96,6 @@ export class SuspendedRunRestorer {
private readonly runState: SuspendedRunStateRegistry;
private readonly dbSnapshotStorage: RunSnapshotCanceller;
private readonly eventBus: RunFinishEventPublisher;
private readonly rebuilder: SuspendedRunRebuilder;
@@ -111,7 +104,6 @@ export class SuspendedRunRestorer {
this.logger = options.logger;
this.pendingConfirmationRepo = options.pendingConfirmationRepo;
this.runState = options.runState;
this.dbSnapshotStorage = options.dbSnapshotStorage;
this.eventBus = options.eventBus;
this.rebuilder = options.rebuilder;
}
@@ -179,26 +171,13 @@ export class SuspendedRunRestorer {
private finalizeUnresumableOrphan(orphan: ClaimedOrphan): void {
try {
// Live SSE clients use this to drop their interactive card.
// Live SSE clients use this to drop their interactive card. History
// needs nothing else: the durable run-finish terminalises the folded
// tree, and the confirmation card renders expired because `claim()`
// consumed its row.
this.publishRunFinish(orphan.threadId, orphan.runId, 'restart_lost_confirmation');
// Terminalise the existing snapshot in place instead of rebuilding
// the tree from the in-memory event bus. After a restart the bus
// only carries the run-finish we just emitted, so a rebuild would
// replace the saved plan/ask card with an empty cancelled tree;
// `markRunCancelled` keeps the plan content intact while flipping
// all in-flight nodes and confirmation buttons off.
void this.dbSnapshotStorage
.markRunCancelled(orphan.threadId, orphan.runId)
.catch((error: unknown) => {
this.logger.warn('Failed to mark orphan snapshot as cancelled', {
requestId: orphan.requestId,
threadId: orphan.threadId,
runId: orphan.runId,
error: getErrorMessage(error),
});
});
} catch (error: unknown) {
this.logger.warn('Failed to finalize orphaned confirmation snapshot', {
this.logger.warn('Failed to finalize orphaned confirmation', {
requestId: orphan.requestId,
error: getErrorMessage(error),
});
@@ -3,8 +3,8 @@ export {
type InstanceAiTracingAiService,
type InstanceAiTracingEventReader,
type InstanceAiTracingRunState,
type InstanceAiTracingEventLog,
type InstanceAiTracingServiceOptions,
type InstanceAiTracingSnapshotStorage,
type MessageTraceFinalization,
type OrchestratorResumeReason,
} from './instance-ai-tracing.service';
@@ -24,7 +24,7 @@ import {
buildInstanceAiRunTraceMetadata,
type InstanceAiRunTraceMetadataOptions,
} from '../run-trace-metadata';
import type { DbSnapshotStorage } from '../storage/db-snapshot-storage';
import type { InstanceAiEventLogRepository } from '../repositories/instance-ai-event-log.repository';
import { TraceReplayState } from '../trace-replay-state';
// Stable UUID namespace for deterministic feedback IDs. Submitting the same
@@ -68,7 +68,7 @@ export type InstanceAiTracingEventReader = {
export type InstanceAiTracingRunState = Pick<RunStateRegistry<User>, 'attachTracing'>;
export type InstanceAiTracingSnapshotStorage = Pick<DbSnapshotStorage, 'findLangsmithAnchor'>;
export type InstanceAiTracingEventLog = Pick<InstanceAiEventLogRepository, 'findLangsmithAnchor'>;
export type InstanceAiTracingAiService = Pick<AiService, 'isProxyEnabled' | 'getClient'>;
@@ -76,7 +76,7 @@ export type InstanceAiTracingServiceOptions = {
logger: Logger;
eventReader: InstanceAiTracingEventReader;
runState: InstanceAiTracingRunState;
dbSnapshotStorage: InstanceAiTracingSnapshotStorage;
eventLog: InstanceAiTracingEventLog;
aiService: InstanceAiTracingAiService;
};
@@ -87,7 +87,7 @@ export type InstanceAiTracingServiceOptions = {
* ID that started an orchestration turn) and the test-only trace replay state.
* Responsible for creating resume trace contexts, finalizing message- and
* run-level trace roots, releasing trace clients, and submitting LangSmith user
* feedback. Collaborators (run state, event bus, snapshot storage, AI service)
* feedback. Collaborators (run state, event bus, event log, AI service)
* are supplied via the options bag because the run-context registry it manages
* is process-local and not suitable for dependency injection.
*/
@@ -112,7 +112,7 @@ export class InstanceAiTracingService {
private readonly runState: InstanceAiTracingRunState;
private readonly dbSnapshotStorage: InstanceAiTracingSnapshotStorage;
private readonly eventLog: InstanceAiTracingEventLog;
private readonly aiService: InstanceAiTracingAiService;
@@ -120,7 +120,7 @@ export class InstanceAiTracingService {
this.logger = options.logger;
this.eventReader = options.eventReader;
this.runState = options.runState;
this.dbSnapshotStorage = options.dbSnapshotStorage;
this.eventLog = options.eventLog;
this.aiService = options.aiService;
}
@@ -490,7 +490,7 @@ export class InstanceAiTracingService {
responseId: string,
payload: { rating: 'up' | 'down'; comment?: string },
): Promise<void> {
const anchor = await this.dbSnapshotStorage.findLangsmithAnchor(threadId, responseId);
const anchor = await this.eventLog.findLangsmithAnchor(threadId, responseId);
if (!anchor) {
this.logger.debug('No LangSmith anchor for feedback; skipping annotation', {
threadId,