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