From 4b3acc85dde7d707e71c0b60e4643b583ea7809d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Wed, 2 Sep 2026 11:29:04 +0000 Subject: [PATCH] refactor(core): Drop the Instance AI run snapshot store (no-changelog) (#37204) --- docs/generated/postgres-schema/README.md | 15 - .../public.instance_ai_run_snapshots.md | 72 -- .../public.instance_ai_threads.md | 16 +- docs/generated/sqlite-schema/README.md | 15 - .../instance_ai_run_snapshots.md | 80 -- .../sqlite-schema/instance_ai_threads.md | 16 +- .../src/schemas/agent-run-reducer.ts | 6 +- .../src/schemas/instance-ai.schema.ts | 12 +- ...784000000051-BackfillInstanceAiEventLog.ts | 6 +- ...6311704-DropInstanceAiRunSnapshotsTable.ts | 44 ++ .../@n8n/instance-ai/docs/architecture.md | 8 +- .../@n8n/instance-ai/docs/configuration.md | 2 +- packages/@n8n/instance-ai/docs/memory.md | 4 +- .../instance-ai/docs/streaming-protocol.md | 12 +- .../resumable-stream-executor.test.ts | 4 +- .../src/runtime/resumable-stream-executor.ts | 46 +- .../instance-ai/src/runtime/stream-runner.ts | 8 - .../src/storage/agent-tree-snapshot.ts | 4 - .../stream/__tests__/output-redaction.test.ts | 356 --------- .../src/stream/consume-with-hitl.ts | 15 - .../src/stream/output-redaction.ts | 286 ------- .../orchestration/eval-setup-agent.tool.ts | 1 - .../instance-ai/src/tracing/trace-payloads.ts | 4 +- packages/@n8n/instance-ai/src/types.ts | 7 - .../src/events/maps/instance-ai.event-map.ts | 4 - .../prometheus/instance-ai-metrics.service.ts | 9 - .../instance-ai-memory.service.test.ts | 355 +-------- ...stance-ai-terminal-outcome.service.test.ts | 240 +++--- .../instance-ai-tracing.service.test.ts | 16 +- .../__tests__/instance-ai.controller.test.ts | 112 +-- .../instance-ai.service.refineTitle.test.ts | 7 +- .../__tests__/instance-ai.service.test.ts | 273 +------ .../__tests__/message-parser.test.ts | 695 +----------------- .../suspended-run-restorer.service.test.ts | 7 - .../src/modules/instance-ai/entities/index.ts | 1 - .../instance-ai-run-snapshot.entity.ts | 34 - .../event-bus/durable-log-metrics.ts | 9 - .../event-bus/in-process-event-bus.ts | 2 +- .../instance-ai/instance-ai-memory.service.ts | 138 +--- .../instance-ai-terminal-outcome.service.ts | 213 ++---- .../instance-ai/instance-ai.controller.ts | 70 +- .../modules/instance-ai/instance-ai.module.ts | 2 - .../instance-ai/instance-ai.service.ts | 249 +------ .../src/modules/instance-ai/message-parser.ts | 323 +------- .../instance-ai-event-log.repository.test.ts | 74 ++ ...nstance-ai-run-snapshot.repository.test.ts | 68 -- .../modules/instance-ai/repositories/index.ts | 1 - .../instance-ai-event-log.repository.ts | 37 +- .../instance-ai-run-snapshot.repository.ts | 36 - .../__tests__/db-snapshot-storage.test.ts | 180 ----- .../storage/db-snapshot-storage.ts | 221 ------ .../src/modules/instance-ai/storage/index.ts | 1 - .../storage/typeorm-agent-memory.ts | 2 +- .../suspended-run-restorer.service.ts | 31 +- .../src/modules/instance-ai/tracing/index.ts | 2 +- .../tracing/instance-ai-tracing.service.ts | 14 +- 56 files changed, 517 insertions(+), 3948 deletions(-) delete mode 100644 docs/generated/postgres-schema/public.instance_ai_run_snapshots.md delete mode 100644 docs/generated/sqlite-schema/instance_ai_run_snapshots.md create mode 100644 packages/@n8n/db/src/migrations/common/1788336311704-DropInstanceAiRunSnapshotsTable.ts delete mode 100644 packages/@n8n/instance-ai/src/stream/__tests__/output-redaction.test.ts delete mode 100644 packages/@n8n/instance-ai/src/stream/output-redaction.ts delete mode 100644 packages/cli/src/modules/instance-ai/entities/instance-ai-run-snapshot.entity.ts delete mode 100644 packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-run-snapshot.repository.test.ts delete mode 100644 packages/cli/src/modules/instance-ai/repositories/instance-ai-run-snapshot.repository.ts delete mode 100644 packages/cli/src/modules/instance-ai/storage/__tests__/db-snapshot-storage.test.ts delete mode 100644 packages/cli/src/modules/instance-ai/storage/db-snapshot-storage.ts diff --git a/docs/generated/postgres-schema/README.md b/docs/generated/postgres-schema/README.md index 6093fbb39ed..54ff7036f14 100644 --- a/docs/generated/postgres-schema/README.md +++ b/docs/generated/postgres-schema/README.md @@ -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 diff --git a/docs/generated/postgres-schema/public.instance_ai_run_snapshots.md b/docs/generated/postgres-schema/public.instance_ai_run_snapshots.md deleted file mode 100644 index bfa6db30318..00000000000 --- a/docs/generated/postgres-schema/public.instance_ai_run_snapshots.md +++ /dev/null @@ -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) diff --git a/docs/generated/postgres-schema/public.instance_ai_threads.md b/docs/generated/postgres-schema/public.instance_ai_threads.md index 0aeab03baa0..34ab3098bc8 100644 --- a/docs/generated/postgres-schema/public.instance_ai_threads.md +++ b/docs/generated/postgres-schema/public.instance_ai_threads.md @@ -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 diff --git a/docs/generated/sqlite-schema/README.md b/docs/generated/sqlite-schema/README.md index 6fc01cbb979..480b9a8b0c5 100644 --- a/docs/generated/sqlite-schema/README.md +++ b/docs/generated/sqlite-schema/README.md @@ -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 diff --git a/docs/generated/sqlite-schema/instance_ai_run_snapshots.md b/docs/generated/sqlite-schema/instance_ai_run_snapshots.md deleted file mode 100644 index b0296d14fa2..00000000000 --- a/docs/generated/sqlite-schema/instance_ai_run_snapshots.md +++ /dev/null @@ -1,80 +0,0 @@ -# instance_ai_run_snapshots - -## Description - -
-Table Definition - -```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")) -``` - -
- -## 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) diff --git a/docs/generated/sqlite-schema/instance_ai_threads.md b/docs/generated/sqlite-schema/instance_ai_threads.md index 6bc2f82bcf2..272f2ffdf2f 100644 --- a/docs/generated/sqlite-schema/instance_ai_threads.md +++ b/docs/generated/sqlite-schema/instance_ai_threads.md @@ -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 diff --git a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts index 7945d39ca51..fe4fb811234 100644 --- a/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts +++ b/packages/@n8n/api-types/src/schemas/agent-run-reducer.ts @@ -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) { diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index ce73119976d..e684c617137 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -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(), }; diff --git a/packages/@n8n/db/src/migrations/common/1784000000051-BackfillInstanceAiEventLog.ts b/packages/@n8n/db/src/migrations/common/1784000000051-BackfillInstanceAiEventLog.ts index 1b910706cdc..3e8ca05e965 100644 --- a/packages/@n8n/db/src/migrations/common/1784000000051-BackfillInstanceAiEventLog.ts +++ b/packages/@n8n/db/src/migrations/common/1784000000051-BackfillInstanceAiEventLog.ts @@ -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). */ // --------------------------------------------------------------------------- diff --git a/packages/@n8n/db/src/migrations/common/1788336311704-DropInstanceAiRunSnapshotsTable.ts b/packages/@n8n/db/src/migrations/common/1788336311704-DropInstanceAiRunSnapshotsTable.ts new file mode 100644 index 00000000000..e1fb61879fe --- /dev/null +++ b/packages/@n8n/db/src/migrations/common/1788336311704-DropInstanceAiRunSnapshotsTable.ts @@ -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; + } +} diff --git a/packages/@n8n/instance-ai/docs/architecture.md b/packages/@n8n/instance-ai/docs/architecture.md index a378cb4b5b5..82370e884d9 100644 --- a/packages/@n8n/instance-ai/docs/architecture.md +++ b/packages/@n8n/instance-ai/docs/architecture.md @@ -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) diff --git a/packages/@n8n/instance-ai/docs/configuration.md b/packages/@n8n/instance-ai/docs/configuration.md index 0ee2e449808..23b89f77456 100644 --- a/packages/@n8n/instance-ai/docs/configuration.md +++ b/packages/@n8n/instance-ai/docs/configuration.md @@ -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 diff --git a/packages/@n8n/instance-ai/docs/memory.md b/packages/@n8n/instance-ai/docs/memory.md index ace567cec89..857c7cb7fa7 100644 --- a/packages/@n8n/instance-ai/docs/memory.md +++ b/packages/@n8n/instance-ai/docs/memory.md @@ -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 diff --git a/packages/@n8n/instance-ai/docs/streaming-protocol.md b/packages/@n8n/instance-ai/docs/streaming-protocol.md index 42e222ef82d..9127279e0f5 100644 --- a/packages/@n8n/instance-ai/docs/streaming-protocol.md +++ b/packages/@n8n/instance-ai/docs/streaming-protocol.md @@ -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 diff --git a/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts b/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts index 630bb4fb6bb..420ca3afb17 100644 --- a/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts +++ b/packages/@n8n/instance-ai/src/runtime/__tests__/resumable-stream-executor.test.ts @@ -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')); diff --git a/packages/@n8n/instance-ai/src/runtime/resumable-stream-executor.ts b/packages/@n8n/instance-ai/src/runtime/resumable-stream-executor.ts index e0802a76978..cfcb3b74169 100644 --- a/packages/@n8n/instance-ai/src/runtime/resumable-stream-executor.ts +++ b/packages/@n8n/instance-ai/src/runtime/resumable-stream-executor.ts @@ -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 { - 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); } diff --git a/packages/@n8n/instance-ai/src/runtime/stream-runner.ts b/packages/@n8n/instance-ai/src/runtime/stream-runner.ts index 69fb6977992..b80f693e204 100644 --- a/packages/@n8n/instance-ai/src/runtime/stream-runner.ts +++ b/packages/@n8n/instance-ai/src/runtime/stream-runner.ts @@ -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, diff --git a/packages/@n8n/instance-ai/src/storage/agent-tree-snapshot.ts b/packages/@n8n/instance-ai/src/storage/agent-tree-snapshot.ts index 2043f96d4be..dc0ff10ef04 100644 --- a/packages/@n8n/instance-ai/src/storage/agent-tree-snapshot.ts +++ b/packages/@n8n/instance-ai/src/storage/agent-tree-snapshot.ts @@ -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; } diff --git a/packages/@n8n/instance-ai/src/stream/__tests__/output-redaction.test.ts b/packages/@n8n/instance-ai/src/stream/__tests__/output-redaction.test.ts deleted file mode 100644 index 326e0a15e5d..00000000000 --- a/packages/@n8n/instance-ai/src/stream/__tests__/output-redaction.test.ts +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/packages/@n8n/instance-ai/src/stream/consume-with-hitl.ts b/packages/@n8n/instance-ai/src/stream/consume-with-hitl.ts index 6c02322dbb5..ec0beb16fef 100644 --- a/packages/@n8n/instance-ai/src/stream/consume-with-hitl.ts +++ b/packages/@n8n/instance-ai/src/stream/consume-with-hitl.ts @@ -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; /** 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' }, }); diff --git a/packages/@n8n/instance-ai/src/stream/output-redaction.ts b/packages/@n8n/instance-ai/src/stream/output-redaction.ts deleted file mode 100644 index df2a4a746bf..00000000000 --- a/packages/@n8n/instance-ai/src/stream/output-redaction.ts +++ /dev/null @@ -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, - 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 { - 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> = {}; - 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 = []; - } -} diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/eval-setup-agent.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/eval-setup-agent.tool.ts index 0d5eb879ba8..735f833a0b4 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/eval-setup-agent.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/eval-setup-agent.tool.ts @@ -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, diff --git a/packages/@n8n/instance-ai/src/tracing/trace-payloads.ts b/packages/@n8n/instance-ai/src/tracing/trace-payloads.ts index 5556899a8b7..c86ac66bb6c 100644 --- a/packages/@n8n/instance-ai/src/tracing/trace-payloads.ts +++ b/packages/@n8n/instance-ai/src/tracing/trace-payloads.ts @@ -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). */ diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index f24048f57ad..4df5e134ea9 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -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) => void; /** * Claim AI credits for a sub-agent stream segment. Wired by the host (cli); diff --git a/packages/cli/src/events/maps/instance-ai.event-map.ts b/packages/cli/src/events/maps/instance-ai.event-map.ts index 3edcb9649d2..ad67ccb3e39 100644 --- a/packages/cli/src/events/maps/instance-ai.event-map.ts +++ b/packages/cli/src/events/maps/instance-ai.event-map.ts @@ -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'; diff --git a/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts b/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts index 33dd37b21c5..ceb368c50cd 100644 --- a/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts +++ b/packages/cli/src/metrics/prometheus/instance-ai-metrics.service.ts @@ -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); }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts index 469954a30db..a21f58bdb10 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-memory.service.test.ts @@ -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()), }; -function makeTree(overrides?: Partial): 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, - }); - }); }); }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-terminal-outcome.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-terminal-outcome.service.test.ts index ee36c9225b8..6631d8dfef1 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-terminal-outcome.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-terminal-outcome.service.test.ts @@ -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 { @@ -155,20 +140,7 @@ function makeTerminalOutcome(overrides: Partial = {}): 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 => - 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' }, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts index 156ce874acb..c2581c9eda7 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai-tracing.service.test.ts @@ -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; eventReader?: Partial; runState?: Partial; - dbSnapshotStorage?: Partial; + eventLog?: Partial; aiService?: Partial; } = {}, ) { @@ -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) }, }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts index 366d30939de..a5351af1daf 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.controller.test.ts @@ -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 void }>({ - setHeader: vi.fn(), - flushHeaders: vi.fn(), - write: vi.fn(), - end: vi.fn(), - flush: vi.fn(), - }); - eventBus.subscribe.mockReturnValue(vi.fn()); - - const sseReq = mock({ - 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 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> = []; + eventBus.subscribe.mockImplementation(() => { + const unsubscribe = vi.fn(); + unsubscribers.push(unsubscribe); + return unsubscribe; + }); let closeHandler: (() => void) | undefined; const sseReq = mock({ @@ -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, diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.refineTitle.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.refineTitle.test.ts index 335645fd9c6..b78bd2522ef 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.refineTitle.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.refineTitle.test.ts @@ -185,13 +185,11 @@ describe('InstanceAiService — finalizeRun title refinement guard', () => { type FinalizeInternals = { publishRunFinish: ReturnType; emitRunMetrics: ReturnType; - saveAgentTreeSnapshot: ReturnType; refineTitleIfNeeded: ReturnType; finalizeRun: ( threadId: string, runId: string, status: 'completed' | 'cancelled' | 'errored', - snapshotStorage: unknown, options?: { userId?: string; modelId?: ModelConfig }, ) => Promise; }; @@ -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(); }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts index 7b50d9fce25..af26ca8ee4d 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.service.test.ts @@ -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>; }; - saveAgentTreeSnapshot: MockedFunction< - ( - threadId: string, - runId: string, - snapshotStorage: unknown, - isUpdate?: boolean, - overrideMessageGroupId?: string, - ) => Promise - >; 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; }; -type SnapshotServiceInternals = { - saveAgentTreeSnapshot: ( - threadId: string, - runId: string, - snapshotStorage: { - getLatest: Mock; - save: Mock; - updateLast: Mock; - }, - isUpdate?: boolean, - overrideMessageGroupId?: string, - ) => Promise; - 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; 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]), diff --git a/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts b/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts index 9460c089745..f3019d284ec 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/message-parser.test.ts @@ -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'); - }); }); }); diff --git a/packages/cli/src/modules/instance-ai/__tests__/suspended-run-restorer.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/suspended-run-restorer.service.test.ts index 93f2b6ef37b..45c3ae2ceb5 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/suspended-run-restorer.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/suspended-run-restorer.service.test.ts @@ -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; pendingConfirmationRepo: MockProxy; runState: MockProxy; - dbSnapshotStorage: MockProxy; eventBus: MockProxy; rebuilder: MockProxy; }; @@ -29,11 +27,9 @@ function createRestorer(): { restorer: SuspendedRunRestorer; mocks: Mocks } { logger: mock(), pendingConfirmationRepo: mock(), runState: mock(), - dbSnapshotStorage: mock(), eventBus: mock(), rebuilder: mock(), }; - 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 () => { diff --git a/packages/cli/src/modules/instance-ai/entities/index.ts b/packages/cli/src/modules/instance-ai/entities/index.ts index 437dd33c641..7527519b783 100644 --- a/packages/cli/src/modules/instance-ai/entities/index.ts +++ b/packages/cli/src/modules/instance-ai/entities/index.ts @@ -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'; diff --git a/packages/cli/src/modules/instance-ai/entities/instance-ai-run-snapshot.entity.ts b/packages/cli/src/modules/instance-ai/entities/instance-ai-run-snapshot.entity.ts deleted file mode 100644 index 32760c3b9c2..00000000000 --- a/packages/cli/src/modules/instance-ai/entities/instance-ai-run-snapshot.entity.ts +++ /dev/null @@ -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; -} diff --git a/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts b/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts index 20cf3c0899f..caca75db6b6 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/durable-log-metrics.ts @@ -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++; } diff --git a/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts b/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts index 889ee1b2f2d..1d8b75fb4ef 100644 --- a/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts +++ b/packages/cli/src/modules/instance-ai/event-bus/in-process-event-bus.ts @@ -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) { diff --git a/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts index cea0e17e456..476a783ba92 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-memory.service.ts @@ -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; } -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 & { createdAt: Date }, ): value is AgentDbMessage & Record { @@ -152,32 +140,6 @@ function expandRunIdsToGroups( return [...expanded]; } -function collectInFlightCheckpointMessages(checkpoints: InstanceAiCheckpoint[]): AgentDbMessage[] { - const merged: AgentDbMessage[] = []; - const seen = new Set(); - 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(); - 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(); @@ -210,14 +172,12 @@ function collectSuspendedHostRunIds(checkpoints: InstanceAiCheckpoint[]): Set, skipRunIds: Set, skipGroupIds: Set, -): { 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(); @@ -252,15 +212,11 @@ function buildLogDerivedSnapshots( lastAt: Date; }; const groups = new Map(); - 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 => { - 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, suspendedRunIds: ReadonlySet, 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 { - 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. diff --git a/packages/cli/src/modules/instance-ai/instance-ai-terminal-outcome.service.ts b/packages/cli/src/modules/instance-ai/instance-ai-terminal-outcome.service.ts index 8a0c65873d2..242dc4d0f57 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai-terminal-outcome.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai-terminal-outcome.service.ts @@ -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; }; -export type InstanceAiTerminalOutcomeSnapshotStorage = Pick< - DbSnapshotStorage, - 'getLatest' | 'save' | 'updateLast' ->; - export type InstanceAiTerminalOutcomeTelemetry = Pick; export type InstanceAiTerminalOutcomeErrorReporter = Pick; @@ -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; } /** @@ -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 { 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 { + async replayUndeliveredTerminalOutcomes(threadId: string): Promise { 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 { - 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 { + ): 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 { @@ -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( diff --git a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts index 61448bbafa5..83d3068b795 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.controller.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.controller.ts @@ -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(); - 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), diff --git a/packages/cli/src/modules/instance-ai/instance-ai.module.ts b/packages/cli/src/modules/instance-ai/instance-ai.module.ts index 88503feb862..31a60df09ce 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.module.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.module.ts @@ -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, diff --git a/packages/cli/src/modules/instance-ai/instance-ai.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.service.ts index deb5a4171f4..3f49e1bd137 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.service.ts @@ -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 { - 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 { - await this.terminalOutcome.replayUndeliveredTerminalOutcomes(threadId, options); + async replayUndeliveredTerminalOutcomes(threadId: string): Promise { + await this.terminalOutcome.replayUndeliveredTerminalOutcomes(threadId); } private async syncPlannedTasksToUi(threadId: string, graph: PlannedTaskGraph): Promise { @@ -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 | 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; 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 { 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( @@ -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 { - 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 []; diff --git a/packages/cli/src/modules/instance-ai/message-parser.ts b/packages/cli/src/modules/instance-ai/message-parser.ts index f6b5e5015d5..94ad34dd595 100644 --- a/packages/cli/src/modules/instance-ai/message-parser.ts +++ b/packages/cli/src/modules/instance-ai/message-parser.ts @@ -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; - 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 { - return typeof value === 'object' && value !== null && !Array.isArray(value) - ? (value as Record) - : {}; -} - -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, @@ -390,18 +156,16 @@ export function parseStoredMessages( // orphan snapshots before, between, or after assistant rows. let nextSnapshotIdx = 0; const consumedSnapshots = new Set(); - // 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(); 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); } diff --git a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts b/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts index 9a559397d0f..1f7e1010f00 100644 --- a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts +++ b/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-event-log.repository.test.ts @@ -88,6 +88,80 @@ describe('InstanceAiEventLogRepository', () => { }); }); + describe('findLangsmithAnchor', () => { + const runStartRow = ( + seq: number, + runId: string, + payload: Record, + ): 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( diff --git a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-run-snapshot.repository.test.ts b/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-run-snapshot.repository.test.ts deleted file mode 100644 index 60044d1bf12..00000000000 --- a/packages/cli/src/modules/instance-ai/repositories/__tests__/instance-ai-run-snapshot.repository.test.ts +++ /dev/null @@ -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) { - const options = find.mock.calls[0][0] as FindManyOptions; - const where = options.where as { createdAt?: FindOperator }; - 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' } })); - }); -}); diff --git a/packages/cli/src/modules/instance-ai/repositories/index.ts b/packages/cli/src/modules/instance-ai/repositories/index.ts index c62999775ea..55a2a275bcf 100644 --- a/packages/cli/src/modules/instance-ai/repositories/index.ts +++ b/packages/cli/src/modules/instance-ai/repositories/index.ts @@ -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'; diff --git a/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts b/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts index 7609a2b6ffb..c0e1f6c44c9 100644 --- a/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts +++ b/packages/cli/src/modules/instance-ai/repositories/instance-ai-event-log.repository.ts @@ -111,8 +111,7 @@ export class InstanceAiEventLogRepository extends Repository { + 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 => + 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 { const row = await this.createQueryBuilder('e') diff --git a/packages/cli/src/modules/instance-ai/repositories/instance-ai-run-snapshot.repository.ts b/packages/cli/src/modules/instance-ai/repositories/instance-ai-run-snapshot.repository.ts deleted file mode 100644 index 83659df52a4..00000000000 --- a/packages/cli/src/modules/instance-ai/repositories/instance-ai-run-snapshot.repository.ts +++ /dev/null @@ -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 { - 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 { - 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' }, - }); - } -} diff --git a/packages/cli/src/modules/instance-ai/storage/__tests__/db-snapshot-storage.test.ts b/packages/cli/src/modules/instance-ai/storage/__tests__/db-snapshot-storage.test.ts deleted file mode 100644 index 7e0f79a0323..00000000000 --- a/packages/cli/src/modules/instance-ai/storage/__tests__/db-snapshot-storage.test.ts +++ /dev/null @@ -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 { - 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(); - 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', {}); - }); - }); -}); diff --git a/packages/cli/src/modules/instance-ai/storage/db-snapshot-storage.ts b/packages/cli/src/modules/instance-ai/storage/db-snapshot-storage.ts deleted file mode 100644 index b407b32a5de..00000000000 --- a/packages/cli/src/modules/instance-ai/storage/db-snapshot-storage.ts +++ /dev/null @@ -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 { - 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(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 { - 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 { - 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 { - const key = { threadId, runId }; - const row = await this.repo.findOneBy(key); - if (!row) return; - - const tree = jsonParse(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 { - const rows = await this.repo.findInWindow(threadId, window); - return rows.map((r) => ({ - tree: jsonParse(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 }; - } -} diff --git a/packages/cli/src/modules/instance-ai/storage/index.ts b/packages/cli/src/modules/instance-ai/storage/index.ts index e19857941c8..00ade2a7cfb 100644 --- a/packages/cli/src/modules/instance-ai/storage/index.ts +++ b/packages/cli/src/modules/instance-ai/storage/index.ts @@ -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'; diff --git a/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts b/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts index 0e61202b579..6fee0ca19c1 100644 --- a/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts +++ b/packages/cli/src/modules/instance-ai/storage/typeorm-agent-memory.ts @@ -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. diff --git a/packages/cli/src/modules/instance-ai/suspended-run-restorer.service.ts b/packages/cli/src/modules/instance-ai/suspended-run-restorer.service.ts index af1f06dcb5e..8aaa7ea282f 100644 --- a/packages/cli/src/modules/instance-ai/suspended-run-restorer.service.ts +++ b/packages/cli/src/modules/instance-ai/suspended-run-restorer.service.ts @@ -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, 'suspendRun' | 'hasLiveRun'>; -/** The slice of snapshot storage the restorer uses to terminalise a snapshot. */ -export type RunSnapshotCanceller = Pick; - /** The slice of the event bus the restorer uses to drop a stale client card. */ export type RunFinishEventPublisher = Pick; @@ -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), }); diff --git a/packages/cli/src/modules/instance-ai/tracing/index.ts b/packages/cli/src/modules/instance-ai/tracing/index.ts index 3ea24518d24..817e345a5b8 100644 --- a/packages/cli/src/modules/instance-ai/tracing/index.ts +++ b/packages/cli/src/modules/instance-ai/tracing/index.ts @@ -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'; diff --git a/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts b/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts index a135441025e..ac6f51568b5 100644 --- a/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts +++ b/packages/cli/src/modules/instance-ai/tracing/instance-ai-tracing.service.ts @@ -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, 'attachTracing'>; -export type InstanceAiTracingSnapshotStorage = Pick; +export type InstanceAiTracingEventLog = Pick; export type InstanceAiTracingAiService = Pick; @@ -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 { - 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,