mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-21 12:51:16 +08:00
feat(instance-ai): Orchestrator-executed checkpoint tasks for planned workflow verification (#29049)
Signed-off-by: Oleg Ivaniv <me@olegivaniv.com>
This commit is contained in:
@@ -151,8 +151,22 @@ Run a built workflow with sidecar pin data for verification (never persisted).
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `workItemId` | string | yes | Work item ID from build outcome |
|
||||
| `workflowId` | string | yes | Workflow ID to execute |
|
||||
| `inputData` | object | no | Trigger payload — **shape depends on trigger type**, see below |
|
||||
| `timeout` | number | no | Max wait in ms (default 300000) |
|
||||
|
||||
**Returns**: `{ executionId, success, status, data?, error? }`
|
||||
**`inputData` shape by trigger type** (the adapter's `getPinDataForTrigger` spreads or wraps based on type — passing the wrong shape produces null downstream values that look like an expression bug):
|
||||
|
||||
| Trigger | Pass | Adapter emits on `$json` |
|
||||
|---|---|---|
|
||||
| Form Trigger | flat field map, e.g. `{name: "Alice", email: "a@b.c"}` | `{ submittedAt, formMode: "instanceAi", name, email, ... }` — matches production. Do NOT wrap in `formFields`. |
|
||||
| Webhook | body payload, e.g. `{event: "signup", userId: "..."}` | `{ headers, query, body: { event, userId, ... } }` |
|
||||
| Chat Trigger | `{chatInput: "..."}` | `{ sessionId, action, chatInput }` |
|
||||
| Schedule | omit | synthetic timestamp fields |
|
||||
|
||||
**Writes on success/failure**: the tool persists a structured `verification` record (`{ attempted, success, executionId, status, evidence, verifiedAt }`) onto the build outcome so subsequent checkpoint turns can reuse it without re-running verify.
|
||||
|
||||
**Returns**: `{ executionId?, success, status?, data?, error? }`
|
||||
|
||||
### `report-verification-verdict` *(conditional)*
|
||||
|
||||
|
||||
@@ -69,6 +69,9 @@ const { ToolSearchProcessor } =
|
||||
require('@mastra/core/processors') as {
|
||||
ToolSearchProcessor: jest.Mock;
|
||||
};
|
||||
const { Agent } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
require('@mastra/core/agent') as { Agent: jest.Mock };
|
||||
|
||||
describe('createInstanceAgent', () => {
|
||||
it('creates a fresh deferred tool processor for each run-scoped toolset', async () => {
|
||||
@@ -106,4 +109,34 @@ describe('createInstanceAgent', () => {
|
||||
'build-workflow-with-agent': { id: 'build-run-2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not attach a workspace to the orchestrator Agent', async () => {
|
||||
Agent.mockClear();
|
||||
const memoryConfig = { storage: { id: 'memory-store' } } as never;
|
||||
const fakeWorkspace = { id: 'should-be-ignored' } as never;
|
||||
|
||||
await createInstanceAgent({
|
||||
modelId: 'test-model',
|
||||
context: {
|
||||
runLabel: 'ws-test',
|
||||
localGatewayStatus: undefined,
|
||||
licenseHints: undefined,
|
||||
localMcpServer: undefined,
|
||||
},
|
||||
orchestrationContext: {
|
||||
runId: 'ws-test',
|
||||
browserMcpConfig: undefined,
|
||||
workspace: fakeWorkspace,
|
||||
},
|
||||
memoryConfig,
|
||||
// Exercise the deprecated field to confirm it is ignored.
|
||||
workspace: fakeWorkspace,
|
||||
} as never);
|
||||
|
||||
expect(Agent).toHaveBeenCalledTimes(1);
|
||||
const calls = Agent.mock.calls as Array<[Record<string, unknown>]>;
|
||||
const firstCall = calls[0];
|
||||
expect(firstCall).toBeDefined();
|
||||
expect(firstCall[0]).not.toHaveProperty('workspace');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,115 @@ describe('getSystemPrompt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('replan branch — must take action', () => {
|
||||
it('requires the orchestrator to take action rather than just acknowledge', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toMatch(/You MUST take action in this same turn/);
|
||||
expect(prompt).toContain('awaiting_replan');
|
||||
expect(prompt).toMatch(/Do NOT reply with an acknowledgement or status update alone/);
|
||||
expect(prompt).toContain('the thread will silently stall');
|
||||
});
|
||||
|
||||
it('lists both single-task (direct tool) and multi-task (create-tasks) routes', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toMatch(/handle a single simple task directly/);
|
||||
expect(prompt).toMatch(/call `create-tasks` for multiple dependent tasks/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('When to Plan — what-am-I-touching axis', () => {
|
||||
it('routes new/multi-workflow/data-table work through plan', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toContain('## When to Plan');
|
||||
expect(prompt).toMatch(/New workflow \(no `workflowId`\), multi-workflow build/);
|
||||
expect(prompt).toMatch(/data tables created or schemas changed/);
|
||||
});
|
||||
|
||||
it('routes existing-workflow edits through bypassPlan', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toMatch(/Any edit to an existing workflow that runs the builder/);
|
||||
expect(prompt).toContain('`bypassPlan: true`');
|
||||
expect(prompt).toContain('existing `workflowId`');
|
||||
});
|
||||
|
||||
it('routes non-build ops through direct tools', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toMatch(/Non-build ops on an existing workflow/);
|
||||
expect(prompt).toContain('The builder does not run.');
|
||||
});
|
||||
|
||||
it('routes replan follow-ups as routing, not re-planning', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toMatch(/Replan follow-up/);
|
||||
expect(prompt).toMatch(/route, don't re-plan/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('post-build verify for bypassPlan', () => {
|
||||
it('instructs the orchestrator to call verify-built-workflow on mockable triggers', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toContain('Post-build flow');
|
||||
expect(prompt).toContain('verify-built-workflow');
|
||||
expect(prompt).toContain('outcome.triggerNodes');
|
||||
expect(prompt).toContain('n8n-nodes-base.scheduleTrigger');
|
||||
expect(prompt).toContain('n8n-nodes-base.webhook');
|
||||
expect(prompt).toContain('@n8n/n8n-nodes-langchain.chatTrigger');
|
||||
expect(prompt).toContain('n8n-nodes-base.formTrigger');
|
||||
});
|
||||
|
||||
it('reads workflowId/workItemId from the outcome field, not result', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toContain('outcome.workflowId');
|
||||
expect(prompt).toContain('outcome.workItemId');
|
||||
expect(prompt).toMatch(/result.*only a short text summary/);
|
||||
});
|
||||
|
||||
it('runs verify even when mocked credentials are present', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toMatch(
|
||||
/Run verify even when `outcome\.mockedCredentialsByNode` is non-empty/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkpoint branch — in-turn patch rule + retry carve-out', () => {
|
||||
it('tells the orchestrator it may patch during a checkpoint and will re-enter the same checkpoint', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toContain('patch in place');
|
||||
expect(prompt).toMatch(
|
||||
/you will receive another `<planned-task-follow-up type="checkpoint">` for the SAME checkpoint/,
|
||||
);
|
||||
expect(prompt).toContain('re-verify');
|
||||
expect(prompt).toContain('complete-checkpoint');
|
||||
});
|
||||
|
||||
it('allows one more in-checkpoint patch if the first surfaced a new narrow bug', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toMatch(/call `complete-checkpoint`.*OR spawn one more in-checkpoint patch/);
|
||||
expect(prompt).toMatch(/Keep the patch count small/);
|
||||
expect(prompt).toMatch(/within two rounds/);
|
||||
});
|
||||
|
||||
it('still warns not to end a checkpoint turn with an unsettled in-turn patch', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
expect(prompt).toMatch(
|
||||
/Do NOT end a checkpoint turn that had an in-turn patch spawned without either calling `complete-checkpoint` on the next re-entry or spawning another bounded patch/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-credential disambiguation guidance', () => {
|
||||
it('instructs the orchestrator to ask once when a service has more than one credential of the same type', () => {
|
||||
const prompt = getSystemPrompt({});
|
||||
|
||||
@@ -217,6 +217,18 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions):
|
||||
branchReadOnly: context.branchReadOnly,
|
||||
});
|
||||
|
||||
// NOTE: we intentionally do NOT pass `workspace` to the orchestrator Agent.
|
||||
// Mastra auto-registers `mastra_workspace_*` tools (execute_command, write_file,
|
||||
// get_process_output, etc.) whenever a workspace is provided. The orchestrator
|
||||
// has no legitimate need for them — it does not run commands or write files —
|
||||
// and the LLM has been observed abusing `execute_command` as a `sleep` primitive
|
||||
// and calling `get_process_output` with `build-*` task IDs that live in a
|
||||
// different namespace than Mastra process PIDs. The workflow-builder subagent
|
||||
// creates its own per-task sandbox via `builderSandboxFactory`; the
|
||||
// `orchestrationContext.workspace` referenced by that factory is untouched.
|
||||
// `options.workspace` is kept on the type as @deprecated for one release so
|
||||
// external callers get a compile-time warning; it is otherwise ignored here.
|
||||
|
||||
const agent = new Agent({
|
||||
id: 'n8n-instance-agent',
|
||||
name: 'n8n Instance Agent',
|
||||
@@ -231,7 +243,6 @@ export async function createInstanceAgent(options: CreateInstanceAgentOptions):
|
||||
tools: hasDeferrableTools ? coreTools : tracedOrchestratorTools,
|
||||
inputProcessors: toolSearchProcessor ? [toolSearchProcessor] : undefined,
|
||||
memory,
|
||||
workspace: options.workspace,
|
||||
});
|
||||
|
||||
mergeTraceRunInputs(
|
||||
|
||||
@@ -189,11 +189,15 @@ You have access to workflow, execution, and credential tools plus a specialized
|
||||
|
||||
## When to Plan
|
||||
|
||||
1. **Single workflow** (build, fix, or modify one workflow): call \`build-workflow-with-agent\` directly — no plan needed.
|
||||
Route by **what you are touching**, not by how risky the change feels:
|
||||
|
||||
2. **Multi-step work** (2+ tasks with dependencies — e.g. data table setup + multiple workflows, or parallel builds + consolidation): call \`plan\` immediately — do NOT ask the user questions first. The planner sub-agent discovers credentials, data tables, and best practices, and will ask the user targeted questions itself if needed — it has far better context about what to ask than you do. Only pass \`guidance\` when the conversation is ambiguous about which approach to take — one sentence, not a rewrite. When \`plan\` returns, tasks are already dispatched.
|
||||
1. **New workflow (no \`workflowId\`), multi-workflow build, or any request that needs data tables created or schemas changed** → call \`plan\`. The planner sub-agent discovers credentials, data tables, and best practices; the orchestrator-run checkpoint independently proves every deliverable works. Do NOT ask the user questions first — the planner asks targeted questions itself if needed. Only pass \`guidance\` when the conversation is ambiguous. When \`plan\` returns, tasks are already dispatched.
|
||||
|
||||
3. **Replanning after failure** (\`<planned-task-follow-up type="replan">\` arrived): inspect the failure details and remaining work. If only one simple task remains (e.g. a single data table operation or credential setup), handle it directly with the appropriate tool (\`manage-data-tables-with-agent\`, \`delegate\`, \`build-workflow-with-agent\`). Use \`create-tasks\` only when multiple dependent tasks still need scheduling — a runtime guard rejects \`create-tasks\` outside a replan context. If replanning is not appropriate, explain the blocker to the user.
|
||||
2. **Any edit to an existing workflow that runs the builder** (add/remove/rewire a node, change an expression, swap a credential, change a schedule, fix a Code node) → call \`build-workflow-with-agent\` directly with \`bypassPlan: true\`, the existing \`workflowId\`, and a one-sentence \`reason\`. A plan-for-every-edit is too slow; the orchestrator runs a lightweight verify afterwards (see **Post-build flow**).
|
||||
|
||||
3. **Non-build ops on an existing workflow** (rename, toggle active, duplicate, move to folder, describe, read executions, publish, delete) → use the specific direct tool (\`workflows\`, \`executions\`, etc.). The builder does not run.
|
||||
|
||||
4. **Replan follow-up** (\`<planned-task-follow-up type="replan">\`) → route, don't re-plan. If one simple task remains (e.g. a single data-table op, credential setup, or single-workflow patch), handle it directly with the matching tool. If multiple dependent tasks still need scheduling, call \`create-tasks\` (a runtime guard rejects \`create-tasks\` outside a replan context). If nothing sensible remains, explain the blocker to the user. **Never end a replan turn with only an acknowledgement** — the scheduler will not fire another follow-up until you act, and the thread will silently stall.
|
||||
|
||||
Use \`task-control(action="update-checklist")\` only for lightweight visible checklists that do not need scheduler-driven execution.
|
||||
|
||||
@@ -207,7 +211,7 @@ When \`credentials(action="setup")\` returns \`needsBrowserSetup=true\`, call \`
|
||||
|
||||
Never use \`delegate\` to build, patch, fix, or update workflows — delegate does not have access to the builder sandbox, verification, or submit tools.
|
||||
|
||||
To fix or modify an existing workflow, use a \`build-workflow\` task (via \`plan\` if multi-step, or \`build-workflow-with-agent\` directly if single) with the existing workflow ID and a spec describing what to change.
|
||||
To edit an existing workflow, call \`build-workflow-with-agent\` directly with \`bypassPlan: true\`, the existing \`workflowId\`, a one-sentence \`reason\`, and a \`task\` spec describing what to change. The orchestrator verifies the result afterwards via \`verify-built-workflow\` when the trigger is mockable (see **Post-build flow**). Use \`plan\` only when the change spans multiple workflows, creates new workflows, or needs new or changed data-table schemas — then the orchestrator-run checkpoint drives verification.
|
||||
|
||||
The detached builder handles node discovery, schema lookups, resource discovery, code generation, validation, and saving. Describe **what** to build (or fix), not **how**: user goal, integrations, credential names, data flow, data table schemas. Don't specify node types or parameter configurations. Mention integrations by service name (Slack, Google Calendar) but don't specify which channels, calendars, spreadsheets, folders, or other resources to use — the builder resolves real resource IDs at build time.
|
||||
|
||||
@@ -225,11 +229,13 @@ Always pass \`conversationContext\` when spawning background agents (\`build-wor
|
||||
|
||||
${SECRET_ASK_GUARDRAIL}
|
||||
|
||||
**Post-build flow** (for direct builds via \`build-workflow-with-agent\`):
|
||||
1. Builder finishes → check if the workflow has mocked credentials, missing parameters, unresolved placeholders, or unconfigured triggers.
|
||||
2. If yes → call \`workflows(action="setup")\` with the workflowId so the user can configure them through the setup UI.
|
||||
**Post-build flow** (for direct \`build-workflow-with-agent\` calls with \`bypassPlan: true\` — plan-driven builds handle their own setup/verify flow via the checkpoint):
|
||||
1. Builder finishes → read \`outcome.workflowId\`, \`outcome.workItemId\`, and \`outcome.triggerNodes\` from the \`<background-task-completed>\` payload's \`outcome\` field (the \`result\` field is only a short text summary). If \`outcome\` is missing, the build did not submit — skip to step 2.
|
||||
- If any \`outcome.triggerNodes[*].nodeType\` matches \`n8n-nodes-base.scheduleTrigger\`, \`n8n-nodes-base.webhook\`, \`@n8n/n8n-nodes-langchain.chatTrigger\`, or \`n8n-nodes-base.formTrigger\`, call \`verify-built-workflow\` with the \`workItemId\` / \`workflowId\` and the trigger-appropriate \`inputData\` shape (see **Per-trigger \`inputData\` shape** below). The verify tool runs the workflow with sidecar pin-data — including the builder's mocked-credential pin data — and cleans up data-table rows it inserted, so it is safe to run without user approval. Run verify even when \`outcome.mockedCredentialsByNode\` is non-empty — the mocked pin data is precisely what it is designed to use.
|
||||
- Skip verify only when: \`outcome.workflowId\` or \`outcome.workItemId\` is missing; \`outcome.hasUnresolvedPlaceholders === true\`; no trigger in \`triggerNodes\` matches a mockable type (polling triggers, OAuth-bound triggers); or the test path requires mocked credentials AND no \`outcome.verificationPinData\` is available (real-credential workflows with no mocked nodes do NOT require pin data — \`verify-built-workflow\` accepts missing pin data).
|
||||
2. If the workflow has mocked credentials, missing parameters, unresolved placeholders, or unconfigured triggers → call \`workflows(action="setup")\` with the workflowId so the user can configure them through the setup UI.
|
||||
3. When \`workflows(action="setup")\` returns \`deferred: true\`, respect the user's decision — do not retry with \`credentials(action="setup")\` or any other setup tool. The user chose to set things up later.
|
||||
4. Ask the user if they want to test the workflow.
|
||||
4. Ask the user if they want to test the workflow (skip this if \`verify-built-workflow\` already proved it works end-to-end).
|
||||
5. Only call \`workflows(action="publish")\` when the user explicitly asks to publish. Never publish automatically.
|
||||
|
||||
## Tool Usage
|
||||
@@ -303,17 +309,29 @@ Working memory persists across all your conversations with this user. Keep it fo
|
||||
|
||||
When \`plan\` or \`create-tasks\` returns, tasks are already running. Write one short sentence acknowledging the work, then end your turn. Do not summarize — the user already approved the plan. Wait for \`<planned-task-follow-up>\` to arrive; do not invent synthetic follow-up turns.
|
||||
|
||||
**Never poll and never sleep.** Background tasks (\`build-workflow-with-agent\`, \`manage-data-tables-with-agent\`, \`research-with-agent\`, \`delegate\`) settle via \`<planned-task-follow-up>\` turns that arrive automatically when work finishes. After you spawn or acknowledge one, end your turn. Do not call \`workflows(action="list")\`, \`executions(action="list")\`, or any shell command to check progress — you will receive a follow-up turn the moment the task settles. If a task appears stuck, tell the user and stop; do not try to detect completion yourself. Do not re-dispatch a build whose task ID is already visible in \`<running-tasks>\` — a duplicate call is rejected with a \`Build already in progress\` message.
|
||||
|
||||
When \`<running-tasks>\` context is present, use it only to reference active task IDs for cancellation or corrections.
|
||||
|
||||
When \`<planned-task-follow-up type="synthesize">\` is present, all planned tasks completed successfully. Read the task outcomes and write the final user-facing completion message. Do not create another plan.
|
||||
When \`<planned-task-follow-up type="synthesize">\` is present, all planned tasks completed successfully. Treat verified workflow drafts as finished deliverables — they are ready to use. Write a concise completion message that names each delivered artifact (data tables, workflows) and summarizes what it does, using the user's time zone for any scheduled timings. Do not hedge with phrases like "ready to go live" or "let me know when you're ready" — the work is done. If any workflow is unpublished, state that plainly as a one-line next-step note ("Publish when you want it live — you can do that from the workflow editor."), not as a gating condition. Do not create another plan.
|
||||
|
||||
When \`<planned-task-follow-up type="replan">\` is present, a planned task failed — apply the replanning branch from \`## When to Plan\` above.
|
||||
When \`<planned-task-follow-up type="replan">\` is present, a planned task failed and the graph is in \`awaiting_replan\`. You MUST take action in this same turn — handle a single simple task directly (matching tool: \`build-workflow-with-agent\`, \`manage-data-tables-with-agent\`, \`delegate\`, etc.), call \`create-tasks\` for multiple dependent tasks, or explain the blocker to the user if nothing sensible remains. Do NOT reply with an acknowledgement or status update alone — the scheduler will not fire another follow-up until you act, and the thread will silently stall. Apply the replan branch from \`## When to Plan\` above.
|
||||
|
||||
When \`<planned-task-follow-up type="checkpoint">\` is present, the block contains exactly one checkpoint task (\`checkpoint.id\`, \`checkpoint.title\`, \`checkpoint.instructions\`, and \`checkpoint.dependsOn\` — the outcomes of prior tasks, including workflow build outcomes with their \`outcome.workItemId\` / \`outcome.workflowId\`). **Always run your own verification — never trust the builder's self-report.** The builder's \`outcome.verification\` is observability metadata, not checkpoint evidence. The checkpoint exists precisely because the builder is a sub-agent whose claims (especially "I verified it works") must be independently proven. Execute \`checkpoint.instructions\` using your tools — typically \`verify-built-workflow\` with the work item ID from the dependency outcome, or \`executions(action="run")\` for a built workflow with real credentials and a testable trigger. Then call \`complete-checkpoint(taskId, status, result)\` **exactly once** to report the outcome (\`status: "succeeded"\` on pass, \`"failed"\` on a verification failure). Do not create a new plan, do not write a user-facing message — the checkpoint card in the plan checklist is the user-visible surface. End your turn as soon as \`complete-checkpoint\` returns.
|
||||
|
||||
When \`<background-task-completed>\` is present, a detached background task (builder, research, data-tables agent) finished. The \`result\` field holds the sub-agent's authoritative summary of what was actually done. **When you write the user-facing recap, take factual details — model IDs, node names, resource IDs, parameter values — directly from this \`result\` text.** Do not substitute values from conversation history or training priors: if the \`result\` says \`gpt-5.4-mini\`, write \`gpt-5.4-mini\`, not "GPT-4o mini" or any other name you associate with the provider. The task spec describes intent; the \`result\` describes what actually happened.
|
||||
|
||||
If the user sends a correction while a build is running, call \`task-control(action="correct-task")\` with the task ID and correction.
|
||||
**If your verification surfaced a bug you can patch in place** (e.g., a Code-node shape issue), you MAY call \`build-workflow-with-agent\` directly during this checkpoint turn to apply the fix. When the patch builder settles, you will receive another \`<planned-task-follow-up type="checkpoint">\` for the SAME checkpoint — re-verify, then on the next re-entry either call \`complete-checkpoint\` (succeeded / failed) OR spawn one more in-checkpoint patch when the first surfaced a new narrow bug. Do NOT end a checkpoint turn that had an in-turn patch spawned without either calling \`complete-checkpoint\` on the next re-entry or spawning another bounded patch. Keep the patch count small: if the issue cannot be narrowed within two rounds, call \`complete-checkpoint(status="failed", error=...)\` with a summary of what remains and let replan take over.
|
||||
|
||||
## Sandbox (Code Execution)
|
||||
### Per-trigger \`inputData\` shape
|
||||
|
||||
When available, \`mastra_workspace_execute_command\` runs shell commands in a persistent isolated sandbox. Use it for code execution, package installation, file processing. The sandbox cannot access the n8n host filesystem — use tool calls for n8n data.`;
|
||||
Used by both the checkpoint verification path and the bypassPlan post-build verify step. The pin-data adapter spreads / wraps based on trigger type — passing the wrong shape gives null downstream values that look like an expression bug:
|
||||
- **Form Trigger** (\`n8n-nodes-base.formTrigger\`) — flat field map, e.g. \`{name: "Alice", email: "a@b.c"}\`. The production Form Trigger emits each field directly on \`$json\`, so the builder's \`$json.<field>\` expressions are correct. **Do NOT wrap in \`formFields\`** — the adapter will reject the call.
|
||||
- **Webhook** (\`n8n-nodes-base.webhook\`) — the body payload, e.g. \`{event: "signup", userId: "..."}\`. The adapter wraps it under \`body\`, so downstream nodes reference \`$json.body.<field>\`.
|
||||
- **Chat Trigger** (\`@n8n/n8n-nodes-langchain.chatTrigger\`) — \`{chatInput: "user message"}\`.
|
||||
- **Schedule Trigger** (\`n8n-nodes-base.scheduleTrigger\`) — omit \`inputData\`; the adapter emits synthetic timestamp fields.
|
||||
|
||||
**Do not patch a workflow first when verify returns null downstream values.** Re-run verify with the corrected \`inputData\` shape. Only patch the workflow if the expression is wrong against the *production* trigger output shape (consult node descriptions), not the \`instanceAi\` pin data path.
|
||||
|
||||
If the user sends a correction while a build is running, call \`task-control(action="correct-task")\` with the task ID and correction.`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Per-agent sampling temperature.
|
||||
*
|
||||
* Lower values make the model more deterministic. Agents that emit
|
||||
* structured output (e.g. workflow SDK code) run colder so "creative"
|
||||
* token choices don't translate into broken artifacts.
|
||||
*/
|
||||
export const TEMPERATURE = {
|
||||
/** Workflow builder — emits strict workflow SDK TypeScript. */
|
||||
BUILDER: 0.2,
|
||||
} as const;
|
||||
@@ -125,7 +125,10 @@ export type {
|
||||
} from './workflow-loop';
|
||||
export { WorkflowLoopRuntime } from './workflow-loop/runtime';
|
||||
export { PlannedTaskCoordinator } from './planned-tasks/planned-task-service';
|
||||
export { applyPlannedTaskPermissions } from './planned-tasks/planned-task-permissions';
|
||||
export {
|
||||
applyPlannedTaskPermissions,
|
||||
PLANNED_TASK_PERMISSION_OVERRIDES,
|
||||
} from './planned-tasks/planned-task-permissions';
|
||||
export type {
|
||||
InstanceAiContext,
|
||||
InstanceAiWorkflowService,
|
||||
@@ -152,6 +155,7 @@ export type {
|
||||
PlannedTaskService,
|
||||
OrchestrationContext,
|
||||
SpawnBackgroundTaskOptions,
|
||||
SpawnBackgroundTaskResult,
|
||||
BackgroundTaskResult,
|
||||
InstanceAiToolTraceOptions,
|
||||
InstanceAiTraceContext,
|
||||
|
||||
+455
-3
@@ -54,7 +54,7 @@ describe('PlannedTaskCoordinator', () => {
|
||||
});
|
||||
|
||||
describe('createPlan', () => {
|
||||
it('saves a valid plan and returns graph', async () => {
|
||||
it('saves a valid plan in awaiting_approval status and returns graph', async () => {
|
||||
const tasks = [makeTask({ id: 'a' }), makeTask({ id: 'b', deps: ['a'] })];
|
||||
|
||||
const result = await coordinator.createPlan('thread-1', tasks, { planRunId: 'run-1' });
|
||||
@@ -63,7 +63,7 @@ describe('PlannedTaskCoordinator', () => {
|
||||
'thread-1',
|
||||
expect.objectContaining({
|
||||
planRunId: 'run-1',
|
||||
status: 'active',
|
||||
status: 'awaiting_approval',
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
tasks: expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'a', status: 'planned' }),
|
||||
@@ -71,7 +71,7 @@ describe('PlannedTaskCoordinator', () => {
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('active');
|
||||
expect(result.status).toBe('awaiting_approval');
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -106,6 +106,36 @@ describe('PlannedTaskCoordinator', () => {
|
||||
coordinator.createPlan('thread-1', tasks, { planRunId: 'run-1' }),
|
||||
).rejects.toThrow('must include at least one tool');
|
||||
});
|
||||
|
||||
it('accepts a checkpoint task that depends on a build-workflow task', async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: 'wf-1' }),
|
||||
makeTask({ id: 'verify-1', kind: 'checkpoint', deps: ['wf-1'] }),
|
||||
];
|
||||
|
||||
const result = await coordinator.createPlan('thread-1', tasks, { planRunId: 'run-1' });
|
||||
|
||||
expect(result.tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('throws when a checkpoint task has no deps', async () => {
|
||||
const tasks = [makeTask({ id: 'verify-1', kind: 'checkpoint', deps: [] })];
|
||||
|
||||
await expect(
|
||||
coordinator.createPlan('thread-1', tasks, { planRunId: 'run-1' }),
|
||||
).rejects.toThrow('must depend on at least one build-workflow task');
|
||||
});
|
||||
|
||||
it('throws when a checkpoint task depends only on non-build-workflow tasks', async () => {
|
||||
const tasks = [
|
||||
makeTask({ id: 'dt-1', kind: 'manage-data-tables' }),
|
||||
makeTask({ id: 'verify-1', kind: 'checkpoint', deps: ['dt-1'] }),
|
||||
];
|
||||
|
||||
await expect(
|
||||
coordinator.createPlan('thread-1', tasks, { planRunId: 'run-1' }),
|
||||
).rejects.toThrow('must depend on at least one build-workflow task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGraph', () => {
|
||||
@@ -348,6 +378,239 @@ describe('PlannedTaskCoordinator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('markCheckpointSucceeded', () => {
|
||||
it('transitions a running checkpoint to succeeded', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [makeTaskRecord({ id: 'verify-1', kind: 'checkpoint', status: 'running' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.markCheckpointSucceeded('thread-1', 'verify-1', {
|
||||
result: 'Verified',
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.graph.tasks[0].status).toBe('succeeded');
|
||||
expect(res.graph.tasks[0].result).toBe('Verified');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when the target task is not found', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({ tasks: [] });
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.markCheckpointSucceeded('thread-1', 'missing', {});
|
||||
|
||||
expect(res).toEqual({ ok: false, reason: 'not-found' });
|
||||
});
|
||||
|
||||
it('rejects when the target task is not a checkpoint', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [makeTaskRecord({ id: 'task-1', kind: 'build-workflow', status: 'running' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.markCheckpointSucceeded('thread-1', 'task-1', {});
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: false,
|
||||
reason: 'wrong-kind',
|
||||
actual: { kind: 'build-workflow' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the checkpoint is not in running state', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [makeTaskRecord({ id: 'verify-1', kind: 'checkpoint', status: 'planned' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.markCheckpointSucceeded('thread-1', 'verify-1', {});
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: false,
|
||||
reason: 'wrong-status',
|
||||
actual: { status: 'planned' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('markCheckpointFailed', () => {
|
||||
it('transitions a running checkpoint to failed with error', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [makeTaskRecord({ id: 'verify-1', kind: 'checkpoint', status: 'running' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.markCheckpointFailed('thread-1', 'verify-1', {
|
||||
error: 'Workflow errored',
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
expect(res.graph.tasks[0].status).toBe('failed');
|
||||
expect(res.graph.tasks[0].error).toBe('Workflow errored');
|
||||
}
|
||||
});
|
||||
|
||||
it('cancels dependent tasks on failure', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [
|
||||
makeTaskRecord({ id: 'verify-1', kind: 'checkpoint', status: 'running' }),
|
||||
makeTaskRecord({
|
||||
id: 'wf-2',
|
||||
kind: 'build-workflow',
|
||||
status: 'planned',
|
||||
deps: ['verify-1'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.markCheckpointFailed('thread-1', 'verify-1', {
|
||||
error: 'boom',
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
const wf2 = res.graph.tasks.find((t) => t.id === 'wf-2');
|
||||
expect(wf2?.status).toBe('cancelled');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when the target task is not a checkpoint', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [makeTaskRecord({ id: 'task-1', kind: 'build-workflow', status: 'running' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.markCheckpointFailed('thread-1', 'task-1', {});
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: false,
|
||||
reason: 'wrong-kind',
|
||||
actual: { kind: 'build-workflow' },
|
||||
});
|
||||
});
|
||||
|
||||
it('persists the structured outcome on the failed checkpoint so replans keep execution context', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [makeTaskRecord({ id: 'verify-1', kind: 'checkpoint', status: 'running' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.markCheckpointFailed('thread-1', 'verify-1', {
|
||||
error: 'Node crashed',
|
||||
outcome: {
|
||||
executionId: 'exec-42',
|
||||
failureNode: 'Insert Row',
|
||||
errorMessage: 'constraint violation',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
const failed = res.graph.tasks.find((t) => t.id === 'verify-1');
|
||||
expect(failed?.status).toBe('failed');
|
||||
expect(failed?.outcome).toEqual({
|
||||
executionId: 'exec-42',
|
||||
failureNode: 'Insert Row',
|
||||
errorMessage: 'constraint violation',
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('revertCheckpointToPlanned', () => {
|
||||
it('rewinds a running checkpoint to planned without touching dependents', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [
|
||||
makeTaskRecord({
|
||||
id: 'verify-1',
|
||||
kind: 'checkpoint',
|
||||
status: 'running',
|
||||
agentId: 'agent-race',
|
||||
startedAt: 123,
|
||||
}),
|
||||
makeTaskRecord({
|
||||
id: 'wf-2',
|
||||
kind: 'build-workflow',
|
||||
status: 'planned',
|
||||
deps: ['verify-1'],
|
||||
}),
|
||||
],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.revertCheckpointToPlanned('thread-1', 'verify-1');
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
if (res.ok) {
|
||||
const verify = res.graph.tasks.find((t) => t.id === 'verify-1');
|
||||
expect(verify?.status).toBe('planned');
|
||||
expect(verify?.agentId).toBeUndefined();
|
||||
expect(verify?.startedAt).toBeUndefined();
|
||||
// Dependents must remain untouched — scheduling race is not a failure.
|
||||
const wf2 = res.graph.tasks.find((t) => t.id === 'wf-2');
|
||||
expect(wf2?.status).toBe('planned');
|
||||
expect(wf2?.error).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects when the target task is not a checkpoint', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [makeTaskRecord({ id: 'task-1', kind: 'build-workflow', status: 'running' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.revertCheckpointToPlanned('thread-1', 'task-1');
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: false,
|
||||
reason: 'wrong-kind',
|
||||
actual: { kind: 'build-workflow' },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the checkpoint is not running', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [makeTaskRecord({ id: 'verify-1', kind: 'checkpoint', status: 'planned' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const res = await coordinator.revertCheckpointToPlanned('thread-1', 'verify-1');
|
||||
|
||||
expect(res).toEqual({
|
||||
ok: false,
|
||||
reason: 'wrong-status',
|
||||
actual: { status: 'planned' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick', () => {
|
||||
it('dispatches ready tasks with all deps satisfied', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
@@ -385,6 +648,92 @@ describe('PlannedTaskCoordinator', () => {
|
||||
expect(action.type).toBe('none');
|
||||
});
|
||||
|
||||
it('returns orchestrate-checkpoint when a checkpoint is ready', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [
|
||||
makeTaskRecord({ id: 'wf-1', kind: 'build-workflow', status: 'succeeded' }),
|
||||
makeTaskRecord({
|
||||
id: 'verify-1',
|
||||
kind: 'checkpoint',
|
||||
deps: ['wf-1'],
|
||||
status: 'planned',
|
||||
}),
|
||||
],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const action = await coordinator.tick('thread-1');
|
||||
|
||||
expect(action.type).toBe('orchestrate-checkpoint');
|
||||
if (action.type === 'orchestrate-checkpoint') {
|
||||
expect(action.tasks).toHaveLength(1);
|
||||
expect(action.tasks[0].id).toBe('verify-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('emits a single checkpoint even when multiple are ready', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [
|
||||
makeTaskRecord({ id: 'wf-1', kind: 'build-workflow', status: 'succeeded' }),
|
||||
makeTaskRecord({ id: 'wf-2', kind: 'build-workflow', status: 'succeeded' }),
|
||||
makeTaskRecord({
|
||||
id: 'verify-1',
|
||||
kind: 'checkpoint',
|
||||
deps: ['wf-1'],
|
||||
status: 'planned',
|
||||
}),
|
||||
makeTaskRecord({
|
||||
id: 'verify-2',
|
||||
kind: 'checkpoint',
|
||||
deps: ['wf-2'],
|
||||
status: 'planned',
|
||||
}),
|
||||
],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const action = await coordinator.tick('thread-1');
|
||||
|
||||
expect(action.type).toBe('orchestrate-checkpoint');
|
||||
if (action.type === 'orchestrate-checkpoint') {
|
||||
expect(action.tasks).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers an orchestrate-checkpoint over a background dispatch', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
tasks: [
|
||||
makeTaskRecord({ id: 'wf-1', kind: 'build-workflow', status: 'succeeded' }),
|
||||
makeTaskRecord({
|
||||
id: 'verify-1',
|
||||
kind: 'checkpoint',
|
||||
deps: ['wf-1'],
|
||||
status: 'planned',
|
||||
}),
|
||||
makeTaskRecord({
|
||||
id: 'wf-2',
|
||||
kind: 'build-workflow',
|
||||
deps: [],
|
||||
status: 'planned',
|
||||
}),
|
||||
],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const action = await coordinator.tick('thread-1');
|
||||
|
||||
expect(action.type).toBe('orchestrate-checkpoint');
|
||||
if (action.type === 'orchestrate-checkpoint') {
|
||||
expect(action.tasks[0].id).toBe('verify-1');
|
||||
}
|
||||
});
|
||||
|
||||
it('triggers replan when a task has failed', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
@@ -466,4 +815,107 @@ describe('PlannedTaskCoordinator', () => {
|
||||
expect(storage.clear).toHaveBeenCalledWith('thread-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('approvePlan', () => {
|
||||
it('transitions awaiting_approval → active', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({ status: 'awaiting_approval' });
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const result = await coordinator.approvePlan('thread-1');
|
||||
|
||||
expect(result?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('leaves an active graph untouched', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({ status: 'active' });
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const result = await coordinator.approvePlan('thread-1');
|
||||
|
||||
expect(result?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('does not resurrect a cancelled graph', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({ status: 'cancelled' });
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const result = await coordinator.approvePlan('thread-1');
|
||||
|
||||
expect(result?.status).toBe('cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tick on awaiting_approval graphs', () => {
|
||||
it('returns none (never dispatches) when graph is awaiting_approval', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
status: 'awaiting_approval',
|
||||
tasks: [makeTaskRecord({ id: 'a', status: 'planned' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const action = await coordinator.tick('thread-1');
|
||||
|
||||
expect(action.type).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('revertToActive', () => {
|
||||
it('flips an awaiting_replan graph back to active', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
status: 'awaiting_replan',
|
||||
tasks: [makeTaskRecord({ id: 'a', status: 'failed', error: 'boom' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const result = await coordinator.revertToActive('thread-1');
|
||||
|
||||
expect(result?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('flips a completed graph back to active', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({
|
||||
status: 'completed',
|
||||
tasks: [makeTaskRecord({ id: 'a', status: 'succeeded' })],
|
||||
});
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const result = await coordinator.revertToActive('thread-1');
|
||||
|
||||
expect(result?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('leaves a cancelled graph untouched', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({ status: 'cancelled' });
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const result = await coordinator.revertToActive('thread-1');
|
||||
|
||||
expect(result?.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('leaves an active graph untouched', async () => {
|
||||
storage.update.mockImplementation(async (_threadId, updater) => {
|
||||
const graph = makeGraph({ status: 'active' });
|
||||
return await Promise.resolve(updater(graph));
|
||||
});
|
||||
|
||||
const result = await coordinator.revertToActive('thread-1');
|
||||
|
||||
expect(result?.status).toBe('active');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { InstanceAiContext, PlannedTaskKind } from '../types';
|
||||
* Destructive actions (delete-data-table), open-ended actions (fetch-url, read-file),
|
||||
* and credential deletion are intentionally excluded — they always require explicit approval.
|
||||
*/
|
||||
const PLANNED_TASK_PERMISSION_OVERRIDES: Partial<
|
||||
export const PLANNED_TASK_PERMISSION_OVERRIDES: Partial<
|
||||
Record<PlannedTaskKind, Partial<InstanceAiPermissions>>
|
||||
> = {
|
||||
'manage-data-tables': {
|
||||
@@ -23,6 +23,12 @@ const PLANNED_TASK_PERMISSION_OVERRIDES: Partial<
|
||||
runWorkflow: 'always_allow',
|
||||
publishWorkflow: 'always_allow',
|
||||
},
|
||||
// Checkpoint tasks run inside an orchestrator follow-up run. Plan approval
|
||||
// authorizes the verification step, so the orchestrator can call
|
||||
// verify-built-workflow / executions(action="run") without a second prompt.
|
||||
checkpoint: {
|
||||
runWorkflow: 'always_allow',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PlannedTaskStorage } from '../storage/planned-task-storage';
|
||||
import type {
|
||||
CheckpointSettleResult,
|
||||
PlannedTask,
|
||||
PlannedTaskGraph,
|
||||
PlannedTaskRecord,
|
||||
@@ -17,6 +18,7 @@ function validateDependencies(tasks: PlannedTask[]): void {
|
||||
}
|
||||
|
||||
const knownIds = new Set(tasks.map((task) => task.id));
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
for (const task of tasks) {
|
||||
for (const depId of task.deps) {
|
||||
if (!knownIds.has(depId)) {
|
||||
@@ -26,11 +28,25 @@ function validateDependencies(tasks: PlannedTask[]): void {
|
||||
if (task.kind === 'delegate' && (!task.tools || task.tools.length === 0)) {
|
||||
throw new Error(`Delegate task "${task.id}" must include at least one tool`);
|
||||
}
|
||||
if (task.kind === 'checkpoint') {
|
||||
if (task.deps.length === 0) {
|
||||
throw new Error(
|
||||
`Checkpoint task "${task.id}" must depend on at least one build-workflow task`,
|
||||
);
|
||||
}
|
||||
const dependsOnBuildWorkflow = task.deps.some(
|
||||
(depId) => byId.get(depId)?.kind === 'build-workflow',
|
||||
);
|
||||
if (!dependsOnBuildWorkflow) {
|
||||
throw new Error(
|
||||
`Checkpoint task "${task.id}" must depend on at least one build-workflow task`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
const byId = new Map(tasks.map((task) => [task.id, task]));
|
||||
|
||||
const visit = (taskId: string) => {
|
||||
if (visited.has(taskId)) return;
|
||||
@@ -96,10 +112,13 @@ export class PlannedTaskCoordinator implements PlannedTaskService {
|
||||
): Promise<PlannedTaskGraph> {
|
||||
validateDependencies(tasks);
|
||||
|
||||
// New plans start in awaiting_approval so tick() (which only acts on
|
||||
// status==='active') cannot dispatch them before the user approves.
|
||||
// Callers flip to 'active' via approvePlan() once approval is confirmed.
|
||||
const graph: PlannedTaskGraph = {
|
||||
planRunId: metadata.planRunId,
|
||||
messageGroupId: metadata.messageGroupId,
|
||||
status: 'active',
|
||||
status: 'awaiting_approval',
|
||||
tasks: tasks.map<PlannedTaskRecord>((task) => ({
|
||||
...task,
|
||||
status: 'planned',
|
||||
@@ -110,6 +129,22 @@ export class PlannedTaskCoordinator implements PlannedTaskService {
|
||||
return graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transition a graph from `awaiting_approval` → `active` after the user
|
||||
* approves the plan. Callers (create-tasks, submit-plan) must invoke this
|
||||
* before schedulePlannedTasks() so tick() can begin dispatching. No-op on
|
||||
* any other status (a cancelled plan stays cancelled, an already-active
|
||||
* plan doesn't regress).
|
||||
*/
|
||||
async approvePlan(threadId: string): Promise<PlannedTaskGraph | null> {
|
||||
return await this.storage.update(threadId, (graph) => {
|
||||
if (graph.status === 'awaiting_approval') {
|
||||
return { ...graph, status: 'active' };
|
||||
}
|
||||
return graph;
|
||||
});
|
||||
}
|
||||
|
||||
async getGraph(threadId: string): Promise<PlannedTaskGraph | null> {
|
||||
return await this.storage.get(threadId);
|
||||
}
|
||||
@@ -185,6 +220,157 @@ export class PlannedTaskCoordinator implements PlannedTaskService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Guarded terminal transition for checkpoint tasks only.
|
||||
* Rejects when the target is missing, not a checkpoint, or not running.
|
||||
* Prevents accidental corruption of the graph via a wrong `taskId`.
|
||||
*/
|
||||
async markCheckpointSucceeded(
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
update: { result?: string; outcome?: Record<string, unknown>; finishedAt?: number },
|
||||
): Promise<CheckpointSettleResult> {
|
||||
let result: CheckpointSettleResult = { ok: false, reason: 'not-found' };
|
||||
|
||||
await this.storage.update(threadId, (graph) => {
|
||||
const task = graph.tasks.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
result = { ok: false, reason: 'not-found' };
|
||||
return graph;
|
||||
}
|
||||
if (task.kind !== 'checkpoint') {
|
||||
result = { ok: false, reason: 'wrong-kind', actual: { kind: task.kind } };
|
||||
return graph;
|
||||
}
|
||||
if (task.status !== 'running') {
|
||||
result = { ok: false, reason: 'wrong-status', actual: { status: task.status } };
|
||||
return graph;
|
||||
}
|
||||
|
||||
const next = updateTaskRecord(graph, taskId, (t) => ({
|
||||
...t,
|
||||
status: 'succeeded',
|
||||
result: update.result ?? t.result,
|
||||
outcome: update.outcome ?? t.outcome,
|
||||
finishedAt: update.finishedAt ?? Date.now(),
|
||||
error: undefined,
|
||||
}));
|
||||
if (!next) {
|
||||
result = { ok: false, reason: 'not-found' };
|
||||
return graph;
|
||||
}
|
||||
result = { ok: true, graph: next };
|
||||
return next;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind a running checkpoint back to `planned` so the next scheduler tick
|
||||
* re-emits the same `orchestrate-checkpoint` action. Used by the service when
|
||||
* `startInternalFollowUpRun` no-ops due to a scheduling race (another run
|
||||
* became active between `markRunning` and the follow-up dispatch). Unlike
|
||||
* `markCheckpointFailed` this does NOT cascade cancel to dependents — the
|
||||
* checkpoint hasn't actually failed, it just lost a schedule slot and will
|
||||
* run on the next tick.
|
||||
*/
|
||||
async revertCheckpointToPlanned(
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
): Promise<CheckpointSettleResult> {
|
||||
let result: CheckpointSettleResult = { ok: false, reason: 'not-found' };
|
||||
|
||||
await this.storage.update(threadId, (graph) => {
|
||||
const task = graph.tasks.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
result = { ok: false, reason: 'not-found' };
|
||||
return graph;
|
||||
}
|
||||
if (task.kind !== 'checkpoint') {
|
||||
result = { ok: false, reason: 'wrong-kind', actual: { kind: task.kind } };
|
||||
return graph;
|
||||
}
|
||||
if (task.status !== 'running') {
|
||||
result = { ok: false, reason: 'wrong-status', actual: { status: task.status } };
|
||||
return graph;
|
||||
}
|
||||
|
||||
const tasks = graph.tasks.map<PlannedTaskRecord>((t) => {
|
||||
if (t.id !== taskId) return t;
|
||||
const { agentId: _agentId, startedAt: _startedAt, ...rest } = t;
|
||||
return { ...rest, status: 'planned' };
|
||||
});
|
||||
|
||||
const next: PlannedTaskGraph = { ...graph, tasks };
|
||||
result = { ok: true, graph: next };
|
||||
return next;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async markCheckpointFailed(
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
update: {
|
||||
error?: string;
|
||||
/** Structured outcome (executionId, failureNode, etc.). Preserved on the
|
||||
* failed task so replans have execution context, not just an error string. */
|
||||
outcome?: Record<string, unknown>;
|
||||
finishedAt?: number;
|
||||
},
|
||||
): Promise<CheckpointSettleResult> {
|
||||
let result: CheckpointSettleResult = { ok: false, reason: 'not-found' };
|
||||
|
||||
await this.storage.update(threadId, (graph) => {
|
||||
const task = graph.tasks.find((t) => t.id === taskId);
|
||||
if (!task) {
|
||||
result = { ok: false, reason: 'not-found' };
|
||||
return graph;
|
||||
}
|
||||
if (task.kind !== 'checkpoint') {
|
||||
result = { ok: false, reason: 'wrong-kind', actual: { kind: task.kind } };
|
||||
return graph;
|
||||
}
|
||||
if (task.status !== 'running') {
|
||||
result = { ok: false, reason: 'wrong-status', actual: { status: task.status } };
|
||||
return graph;
|
||||
}
|
||||
|
||||
const dependents = collectDependents(graph, taskId);
|
||||
const finishedAt = update.finishedAt ?? Date.now();
|
||||
const failureError = update.error ?? task.error ?? 'Checkpoint failed';
|
||||
|
||||
const tasks = graph.tasks.map<PlannedTaskRecord>((t) => {
|
||||
if (t.id === taskId) {
|
||||
return {
|
||||
...t,
|
||||
status: 'failed',
|
||||
error: failureError,
|
||||
outcome: update.outcome ?? t.outcome,
|
||||
finishedAt,
|
||||
};
|
||||
}
|
||||
if (dependents.has(t.id) && (t.status === 'planned' || t.status === 'running')) {
|
||||
return {
|
||||
...t,
|
||||
status: 'cancelled',
|
||||
error: `Cancelled: dependency "${taskId}" failed`,
|
||||
finishedAt,
|
||||
};
|
||||
}
|
||||
return t;
|
||||
});
|
||||
|
||||
const next: PlannedTaskGraph = { ...graph, tasks };
|
||||
result = { ok: true, graph: next };
|
||||
return next;
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async markCancelled(
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
@@ -254,6 +440,14 @@ export class PlannedTaskCoordinator implements PlannedTaskService {
|
||||
return graph;
|
||||
}
|
||||
|
||||
// Checkpoints run inline in the orchestrator (sequential, one per follow-up run).
|
||||
// Give them priority over background dispatch to keep sequencing clean.
|
||||
const readyCheckpoint = readyTasks.find((t) => t.kind === 'checkpoint');
|
||||
if (readyCheckpoint) {
|
||||
action = { type: 'orchestrate-checkpoint', graph, tasks: [readyCheckpoint] };
|
||||
return graph;
|
||||
}
|
||||
|
||||
action = { type: 'dispatch', graph, tasks: readyTasks.slice(0, availableSlots) };
|
||||
return graph;
|
||||
});
|
||||
@@ -265,4 +459,24 @@ export class PlannedTaskCoordinator implements PlannedTaskService {
|
||||
async clear(threadId: string): Promise<void> {
|
||||
await this.storage.clear(threadId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revert the graph's status back to `active` when a pending follow-up action
|
||||
* (replan or synthesize) couldn't be dispatched — typically because a user
|
||||
* chat run was live at the moment the scheduler tried to start the internal
|
||||
* follow-up. Without this, tick() returns `none` for non-active graphs and
|
||||
* the follow-up is silently lost. The next tick sees the graph active again
|
||||
* and re-emits the same action.
|
||||
*
|
||||
* Only transitions from `awaiting_replan` or `completed` — never from
|
||||
* `cancelled` (a user-cancelled plan must stay cancelled).
|
||||
*/
|
||||
async revertToActive(threadId: string): Promise<PlannedTaskGraph | null> {
|
||||
return await this.storage.update(threadId, (graph) => {
|
||||
if (graph.status === 'awaiting_replan' || graph.status === 'completed') {
|
||||
return { ...graph, status: 'active' };
|
||||
}
|
||||
return graph;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('BackgroundTaskManager', () => {
|
||||
it('spawns a task and tracks it as running', () => {
|
||||
const result = manager.spawn(makeSpawnOptions());
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(result.status).toBe('started');
|
||||
expect(manager.getRunningTasks('thread-1')).toHaveLength(1);
|
||||
expect(manager.getRunningTasks('thread-1')[0].taskId).toBe('task-1');
|
||||
});
|
||||
@@ -50,7 +50,7 @@ describe('BackgroundTaskManager', () => {
|
||||
|
||||
const result = manager.spawn(makeSpawnOptions({ taskId: 't4', onLimitReached }));
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(result.status).toBe('limit-reached');
|
||||
expect(onLimitReached).toHaveBeenCalledWith(expect.stringContaining('limit of 3'));
|
||||
});
|
||||
|
||||
@@ -170,6 +170,177 @@ describe('BackgroundTaskManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-flight dedupe', () => {
|
||||
it('returns duplicate when plannedTaskId matches a running task', () => {
|
||||
const first = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'first',
|
||||
run: async () => await new Promise(() => {}),
|
||||
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-1' },
|
||||
}),
|
||||
);
|
||||
expect(first.status).toBe('started');
|
||||
|
||||
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'second',
|
||||
run,
|
||||
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-1' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(second.status).toBe('duplicate');
|
||||
if (second.status === 'duplicate') {
|
||||
expect(second.existing.taskId).toBe('first');
|
||||
}
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
expect(manager.getRunningTasks('thread-1')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('allows a new spawn once the first planned-task settles', async () => {
|
||||
const { promise, resolve } = createDeferred<string>();
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'first',
|
||||
run: async () => await promise,
|
||||
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-2' },
|
||||
}),
|
||||
);
|
||||
|
||||
resolve('done');
|
||||
await flushPromises();
|
||||
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'second',
|
||||
run: async () => await new Promise(() => {}),
|
||||
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-2' },
|
||||
}),
|
||||
);
|
||||
expect(second.status).toBe('started');
|
||||
});
|
||||
|
||||
it('returns duplicate when workflowId + role matches a running task without plannedTaskId', () => {
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'first',
|
||||
run: async () => await new Promise(() => {}),
|
||||
dedupeKey: { role: 'workflow-builder', workflowId: 'wf-1' },
|
||||
}),
|
||||
);
|
||||
|
||||
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'second',
|
||||
run,
|
||||
dedupeKey: { role: 'workflow-builder', workflowId: 'wf-1' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(second.status).toBe('duplicate');
|
||||
expect(run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not collapse two distinct plannedTaskIds that target the same workflowId', () => {
|
||||
// A planner may emit two work items for the same workflow — e.g., initial
|
||||
// build (planned-A) followed by a patch (planned-B). They are distinct
|
||||
// planned tasks and must both run; collapsing them on workflowId would
|
||||
// skip work the user approved.
|
||||
const first = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'task-A',
|
||||
run: async () => await new Promise(() => {}),
|
||||
dedupeKey: {
|
||||
role: 'workflow-builder',
|
||||
plannedTaskId: 'planned-A',
|
||||
workflowId: 'wf-shared',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(first.status).toBe('started');
|
||||
|
||||
const run = jest.fn(async (): Promise<string> => await new Promise(() => {}));
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'task-B',
|
||||
run,
|
||||
dedupeKey: {
|
||||
role: 'workflow-builder',
|
||||
plannedTaskId: 'planned-B',
|
||||
workflowId: 'wf-shared',
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(second.status).toBe('started');
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getRunningTasks('thread-1')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not dedupe across roles for the same workflowId', () => {
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'builder',
|
||||
role: 'workflow-builder',
|
||||
run: async () => await new Promise(() => {}),
|
||||
dedupeKey: { role: 'workflow-builder', workflowId: 'wf-1' },
|
||||
}),
|
||||
);
|
||||
|
||||
const other = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'researcher',
|
||||
role: 'web-researcher',
|
||||
run: async () => await new Promise(() => {}),
|
||||
dedupeKey: { role: 'web-researcher', workflowId: 'wf-1' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(other.status).toBe('started');
|
||||
expect(manager.getRunningTasks('thread-1')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('limit-reached still fires even when dedupe would have passed', () => {
|
||||
const filler = makeSpawnOptions({ run: async () => await new Promise(() => {}) });
|
||||
manager.spawn({ ...filler, taskId: 't1' });
|
||||
manager.spawn({ ...filler, taskId: 't2' });
|
||||
manager.spawn({ ...filler, taskId: 't3' });
|
||||
|
||||
const onLimitReached = jest.fn();
|
||||
const result = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 't4',
|
||||
onLimitReached,
|
||||
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-fresh' },
|
||||
}),
|
||||
);
|
||||
expect(result.status).toBe('limit-reached');
|
||||
expect(onLimitReached).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancelTask releases dedupe indices so a fresh spawn is allowed', () => {
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'first',
|
||||
run: async () => await new Promise(() => {}),
|
||||
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-3' },
|
||||
}),
|
||||
);
|
||||
manager.cancelTask('thread-1', 'first');
|
||||
|
||||
const second = manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'second',
|
||||
run: async () => await new Promise(() => {}),
|
||||
dedupeKey: { role: 'workflow-builder', plannedTaskId: 'planned-3' },
|
||||
}),
|
||||
);
|
||||
expect(second.status).toBe('started');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queueCorrection', () => {
|
||||
it('queues correction for running task', () => {
|
||||
manager.spawn(makeSpawnOptions({ run: async () => await new Promise(() => {}) }));
|
||||
@@ -374,6 +545,70 @@ describe('BackgroundTaskManager', () => {
|
||||
expect(manager.getRunningTasks('thread-1')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRunningTasksByParentCheckpoint', () => {
|
||||
it('returns running tasks tagged with the given checkpoint id', () => {
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'child-1',
|
||||
run: async () => await new Promise(() => {}),
|
||||
parentCheckpointId: 'cp-verify-1',
|
||||
}),
|
||||
);
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'child-2',
|
||||
run: async () => await new Promise(() => {}),
|
||||
parentCheckpointId: 'cp-verify-1',
|
||||
}),
|
||||
);
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'unrelated',
|
||||
run: async () => await new Promise(() => {}),
|
||||
}),
|
||||
);
|
||||
|
||||
const children = manager.getRunningTasksByParentCheckpoint('thread-1', 'cp-verify-1');
|
||||
expect(children.map((c) => c.taskId).sort()).toEqual(['child-1', 'child-2']);
|
||||
});
|
||||
|
||||
it('excludes tasks tagged under a different checkpoint', () => {
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'child-a',
|
||||
run: async () => await new Promise(() => {}),
|
||||
parentCheckpointId: 'cp-A',
|
||||
}),
|
||||
);
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'child-b',
|
||||
run: async () => await new Promise(() => {}),
|
||||
parentCheckpointId: 'cp-B',
|
||||
}),
|
||||
);
|
||||
|
||||
const childrenA = manager.getRunningTasksByParentCheckpoint('thread-1', 'cp-A');
|
||||
expect(childrenA.map((c) => c.taskId)).toEqual(['child-a']);
|
||||
});
|
||||
|
||||
it('excludes tasks that have already settled', async () => {
|
||||
const { promise, resolve } = createDeferred<string>();
|
||||
manager.spawn(
|
||||
makeSpawnOptions({
|
||||
taskId: 'child-done',
|
||||
parentCheckpointId: 'cp-verify-1',
|
||||
run: async () => await promise,
|
||||
}),
|
||||
);
|
||||
|
||||
resolve('done');
|
||||
await flushPromises();
|
||||
|
||||
expect(manager.getRunningTasksByParentCheckpoint('thread-1', 'cp-verify-1')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichMessageWithRunningTasks', () => {
|
||||
|
||||
@@ -21,6 +21,25 @@ export interface ManagedBackgroundTask {
|
||||
plannedTaskId?: string;
|
||||
workItemId?: string;
|
||||
traceContext?: InstanceAiTraceContext;
|
||||
/** Identity used for single-flight dedupe lookups; copied from the spawn options. */
|
||||
dedupeKey?: BackgroundTaskDedupeKey;
|
||||
/**
|
||||
* The checkpoint task id this background task was spawned under, when the
|
||||
* orchestrator called a detached sub-agent tool inside a
|
||||
* `<planned-task-follow-up type="checkpoint">` turn. The checkpoint safety
|
||||
* net uses this to tell "orchestrator exited silently" apart from
|
||||
* "orchestrator handed off to an in-flight patch builder".
|
||||
*/
|
||||
parentCheckpointId?: string;
|
||||
}
|
||||
|
||||
export interface BackgroundTaskDedupeKey {
|
||||
/** Planned-task graph ID this background task is dispatched for. Primary dedupe key. */
|
||||
plannedTaskId?: string;
|
||||
/** Target workflow ID for this background task. Fallback dedupe key when there is no planned task. */
|
||||
workflowId?: string;
|
||||
/** Agent role (e.g. 'workflow-builder'). Scopes the workflowId fallback so different roles against the same workflow don't collide. */
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface SpawnManagedBackgroundTaskOptions {
|
||||
@@ -33,6 +52,22 @@ export interface SpawnManagedBackgroundTaskOptions {
|
||||
plannedTaskId?: string;
|
||||
workItemId?: string;
|
||||
traceContext?: InstanceAiTraceContext;
|
||||
/**
|
||||
* Identity for single-flight dedupe. When supplied, a spawn with the same `plannedTaskId`
|
||||
* (primary) or `role + workflowId` (fallback) as a currently-running task returns
|
||||
* `{ status: 'duplicate', existing }` instead of launching a second task.
|
||||
*/
|
||||
dedupeKey?: BackgroundTaskDedupeKey;
|
||||
/**
|
||||
* Link this background task to a running checkpoint in the planned-task
|
||||
* graph. Set when the orchestrator spawns a detached sub-agent (builder,
|
||||
* research, data-table, delegate) from inside a
|
||||
* `<planned-task-follow-up type="checkpoint">` turn. The post-run safety
|
||||
* net defers failing the checkpoint while any child with this id is still
|
||||
* running, and the settlement path re-emits the checkpoint follow-up when
|
||||
* the last child settles.
|
||||
*/
|
||||
parentCheckpointId?: string;
|
||||
run: (
|
||||
signal: AbortSignal,
|
||||
drainCorrections: () => string[],
|
||||
@@ -44,6 +79,11 @@ export interface SpawnManagedBackgroundTaskOptions {
|
||||
onSettled?: (task: ManagedBackgroundTask) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type SpawnManagedBackgroundTaskResult =
|
||||
| { status: 'started'; task: ManagedBackgroundTask }
|
||||
| { status: 'limit-reached' }
|
||||
| { status: 'duplicate'; existing: ManagedBackgroundTask };
|
||||
|
||||
export interface BackgroundTaskMessageOptions<
|
||||
TTask extends ManagedBackgroundTask = ManagedBackgroundTask,
|
||||
> {
|
||||
@@ -52,9 +92,51 @@ export interface BackgroundTaskMessageOptions<
|
||||
|
||||
export class BackgroundTaskManager {
|
||||
private readonly tasks = new Map<string, ManagedBackgroundTask>();
|
||||
/** plannedTaskId → taskId for the currently-running task. Populated only when the caller provides a dedupeKey with plannedTaskId. */
|
||||
private readonly byPlannedTaskId = new Map<string, string>();
|
||||
/**
|
||||
* `${role}:${workflowId}` → taskId for the currently-running task. Only
|
||||
* populated (and only consulted) when the caller provides a dedupeKey
|
||||
* WITHOUT a plannedTaskId. When both keys are present we treat
|
||||
* plannedTaskId as the canonical identity — two distinct planned tasks may
|
||||
* legitimately target the same workflow (e.g., build + later patch) and
|
||||
* must not collapse into each other.
|
||||
*/
|
||||
private readonly byRoleAndWorkflowId = new Map<string, string>();
|
||||
|
||||
constructor(private readonly maxConcurrentPerThread = 5) {}
|
||||
|
||||
private workflowKey(role: string, workflowId: string): string {
|
||||
return `${role}:${workflowId}`;
|
||||
}
|
||||
|
||||
private findDuplicate(
|
||||
dedupeKey: BackgroundTaskDedupeKey | undefined,
|
||||
): ManagedBackgroundTask | undefined {
|
||||
if (!dedupeKey) return undefined;
|
||||
if (dedupeKey.plannedTaskId) {
|
||||
// plannedTaskId is the canonical identity when present — we must NOT
|
||||
// fall back to the workflowId index, otherwise distinct planned tasks
|
||||
// targeting the same (role, workflowId) would falsely collapse.
|
||||
const existingId = this.byPlannedTaskId.get(dedupeKey.plannedTaskId);
|
||||
if (existingId) {
|
||||
const existing = this.tasks.get(existingId);
|
||||
if (existing && existing.status === 'running') return existing;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (dedupeKey.workflowId) {
|
||||
const existingId = this.byRoleAndWorkflowId.get(
|
||||
this.workflowKey(dedupeKey.role, dedupeKey.workflowId),
|
||||
);
|
||||
if (existingId) {
|
||||
const existing = this.tasks.get(existingId);
|
||||
if (existing && existing.status === 'running') return existing;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getTaskSnapshots(threadId: string): ManagedBackgroundTask[] {
|
||||
return [...this.tasks.values()].filter((task) => task.threadId === threadId);
|
||||
}
|
||||
@@ -65,6 +147,24 @@ export class BackgroundTaskManager {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all running background tasks on this thread that were spawned
|
||||
* under the given checkpoint task id. Used by the checkpoint safety net to
|
||||
* defer failing a checkpoint while a detached patch/research/data-table
|
||||
* sub-agent it just launched is still in-flight.
|
||||
*/
|
||||
getRunningTasksByParentCheckpoint(
|
||||
threadId: string,
|
||||
checkpointTaskId: string,
|
||||
): ManagedBackgroundTask[] {
|
||||
return [...this.tasks.values()].filter(
|
||||
(task) =>
|
||||
task.threadId === threadId &&
|
||||
task.status === 'running' &&
|
||||
task.parentCheckpointId === checkpointTaskId,
|
||||
);
|
||||
}
|
||||
|
||||
queueCorrection(
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
@@ -89,6 +189,7 @@ export class BackgroundTaskManager {
|
||||
task.abortController.abort();
|
||||
task.status = 'cancelled';
|
||||
this.tasks.delete(taskId);
|
||||
this.releaseDedupeIndices(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -100,6 +201,7 @@ export class BackgroundTaskManager {
|
||||
task.status = 'cancelled';
|
||||
cancelled.push(task);
|
||||
this.tasks.delete(taskId);
|
||||
this.releaseDedupeIndices(task);
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
@@ -110,17 +212,21 @@ export class BackgroundTaskManager {
|
||||
task.abortController.abort();
|
||||
cancelled.push(task);
|
||||
this.tasks.delete(taskId);
|
||||
this.releaseDedupeIndices(task);
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
spawn(options: SpawnManagedBackgroundTaskOptions): boolean {
|
||||
spawn(options: SpawnManagedBackgroundTaskOptions): SpawnManagedBackgroundTaskResult {
|
||||
const duplicate = this.findDuplicate(options.dedupeKey);
|
||||
if (duplicate) return { status: 'duplicate', existing: duplicate };
|
||||
|
||||
const runningCount = this.getRunningTasks(options.threadId).length;
|
||||
if (runningCount >= this.maxConcurrentPerThread) {
|
||||
options.onLimitReached?.(
|
||||
`Cannot start background task: limit of ${this.maxConcurrentPerThread} concurrent tasks reached. Wait for existing tasks to complete.`,
|
||||
);
|
||||
return false;
|
||||
return { status: 'limit-reached' };
|
||||
}
|
||||
|
||||
const task: ManagedBackgroundTask = {
|
||||
@@ -137,11 +243,41 @@ export class BackgroundTaskManager {
|
||||
plannedTaskId: options.plannedTaskId,
|
||||
workItemId: options.workItemId,
|
||||
traceContext: options.traceContext,
|
||||
dedupeKey: options.dedupeKey,
|
||||
parentCheckpointId: options.parentCheckpointId,
|
||||
};
|
||||
|
||||
this.tasks.set(options.taskId, task);
|
||||
if (options.dedupeKey?.plannedTaskId) {
|
||||
this.byPlannedTaskId.set(options.dedupeKey.plannedTaskId, options.taskId);
|
||||
} else if (options.dedupeKey?.workflowId) {
|
||||
// Only index by (role, workflowId) when there is no plannedTaskId.
|
||||
// Otherwise a later spawn for a different planned task targeting the
|
||||
// same workflow would be wrongly matched against this one.
|
||||
this.byRoleAndWorkflowId.set(
|
||||
this.workflowKey(options.dedupeKey.role, options.dedupeKey.workflowId),
|
||||
options.taskId,
|
||||
);
|
||||
}
|
||||
void this.executeTask(task, options);
|
||||
return true;
|
||||
return { status: 'started', task };
|
||||
}
|
||||
|
||||
private releaseDedupeIndices(task: ManagedBackgroundTask): void {
|
||||
const key = task.dedupeKey;
|
||||
if (!key) return;
|
||||
if (key.plannedTaskId) {
|
||||
if (this.byPlannedTaskId.get(key.plannedTaskId) === task.taskId) {
|
||||
this.byPlannedTaskId.delete(key.plannedTaskId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.workflowId) {
|
||||
const wfKey = this.workflowKey(key.role, key.workflowId);
|
||||
if (this.byRoleAndWorkflowId.get(wfKey) === task.taskId) {
|
||||
this.byRoleAndWorkflowId.delete(wfKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async executeTask(
|
||||
@@ -185,6 +321,7 @@ export class BackgroundTaskManager {
|
||||
}
|
||||
} finally {
|
||||
this.tasks.delete(task.taskId);
|
||||
this.releaseDedupeIndices(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ export interface SuspendedRunState<TUser = unknown> extends ActiveRunState {
|
||||
toolCallId: string;
|
||||
requestId: string;
|
||||
createdAt: number;
|
||||
/** Set when the suspended run was a planned-task checkpoint follow-up.
|
||||
* Preserved across suspend/resume so the resumed run's finalizer can
|
||||
* run the deadlock fallback and reschedule. */
|
||||
checkpoint?: { isCheckpointFollowUp: true; checkpointTaskId: string };
|
||||
}
|
||||
|
||||
export interface ConfirmationData {
|
||||
@@ -85,6 +89,9 @@ export class RunStateRegistry<TUser = unknown> {
|
||||
|
||||
private readonly runIdsByMessageGroup = new Map<string, string[]>();
|
||||
|
||||
/** IANA time zone captured at initial-run entry and reused by follow-up runs. */
|
||||
private readonly threadTimeZones = new Map<string, string>();
|
||||
|
||||
startRun(options: StartRunOptions<TUser>): StartedRunState {
|
||||
const runId = `run_${nanoid()}`;
|
||||
const abortController = new AbortController();
|
||||
@@ -270,6 +277,14 @@ export class RunStateRegistry<TUser = unknown> {
|
||||
return this.threadResearchMode.get(threadId);
|
||||
}
|
||||
|
||||
setTimeZone(threadId: string, timeZone: string): void {
|
||||
this.threadTimeZones.set(threadId, timeZone);
|
||||
}
|
||||
|
||||
getTimeZone(threadId: string): string | undefined {
|
||||
return this.threadTimeZones.get(threadId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find suspended runs and pending confirmations older than `maxAgeMs`.
|
||||
* Returns thread IDs and request IDs that should be cancelled/rejected.
|
||||
@@ -330,6 +345,7 @@ export class RunStateRegistry<TUser = unknown> {
|
||||
|
||||
this.threadUsers.delete(threadId);
|
||||
this.threadResearchMode.delete(threadId);
|
||||
this.threadTimeZones.delete(threadId);
|
||||
|
||||
const groupId = this.threadMessageGroupId.get(threadId);
|
||||
if (groupId) this.runIdsByMessageGroup.delete(groupId);
|
||||
@@ -354,6 +370,7 @@ export class RunStateRegistry<TUser = unknown> {
|
||||
this.pendingConfirmations.clear();
|
||||
this.threadUsers.clear();
|
||||
this.threadResearchMode.clear();
|
||||
this.threadTimeZones.clear();
|
||||
this.threadMessageGroupId.clear();
|
||||
this.runIdsByMessageGroup.clear();
|
||||
|
||||
|
||||
@@ -1,70 +1,117 @@
|
||||
import type { Memory } from '@mastra/memory';
|
||||
|
||||
jest.mock('../thread-patch', () => ({
|
||||
patchThread: jest.fn(),
|
||||
}));
|
||||
|
||||
import type { PlannedTaskGraph } from '../../types';
|
||||
import { PlannedTaskStorage } from '../planned-task-storage';
|
||||
import { patchThread } from '../thread-patch';
|
||||
|
||||
jest.mock('../thread-patch', () => ({
|
||||
patchThread: jest.fn(
|
||||
(
|
||||
_memory: Memory,
|
||||
opts: {
|
||||
threadId: string;
|
||||
update: (thread: { metadata?: Record<string, unknown> }) => {
|
||||
metadata: Record<string, unknown>;
|
||||
};
|
||||
},
|
||||
) => {
|
||||
const currentMetadata = metadataByThread.get(opts.threadId) ?? {};
|
||||
const next = opts.update({ metadata: currentMetadata });
|
||||
metadataByThread.set(opts.threadId, next.metadata);
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const metadataByThread = new Map<string, Record<string, unknown>>();
|
||||
const mockedPatchThread = jest.mocked(patchThread);
|
||||
|
||||
function makeMemory(): Memory {
|
||||
return {
|
||||
getThreadById: jest.fn(({ threadId }: { threadId: string }) => ({
|
||||
id: threadId,
|
||||
title: 'Test',
|
||||
metadata: metadataByThread.get(threadId),
|
||||
resourceId: 'res-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})),
|
||||
getThreadById: jest.fn(),
|
||||
} as unknown as Memory;
|
||||
}
|
||||
|
||||
function baseGraph(): PlannedTaskGraph {
|
||||
function makeGraph(overrides: Partial<PlannedTaskGraph> = {}): PlannedTaskGraph {
|
||||
return {
|
||||
planRunId: 'run-1',
|
||||
status: 'active',
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
title: 'Build',
|
||||
id: 'build-1',
|
||||
title: 'Build workflow',
|
||||
kind: 'build-workflow',
|
||||
spec: 'spec',
|
||||
spec: 'Build it',
|
||||
deps: [],
|
||||
status: 'running',
|
||||
status: 'planned',
|
||||
},
|
||||
{
|
||||
id: 'verify-1',
|
||||
title: "Verify 'build-1' workflow runs successfully",
|
||||
kind: 'checkpoint',
|
||||
spec: 'Call verify-built-workflow with the build outcome.',
|
||||
deps: ['build-1'],
|
||||
status: 'planned',
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PlannedTaskStorage', () => {
|
||||
let memory: Memory;
|
||||
let storage: PlannedTaskStorage;
|
||||
|
||||
beforeEach(() => {
|
||||
metadataByThread.clear();
|
||||
jest.clearAllMocks();
|
||||
memory = makeMemory();
|
||||
storage = new PlannedTaskStorage(memory);
|
||||
});
|
||||
|
||||
it('round-trips a graph through save -> get', async () => {
|
||||
const storage = new PlannedTaskStorage(makeMemory());
|
||||
const graph = baseGraph();
|
||||
describe('get() kind parsing', () => {
|
||||
it('round-trips a graph containing a checkpoint task', async () => {
|
||||
const graph = makeGraph();
|
||||
(memory.getThreadById as jest.Mock).mockResolvedValue({
|
||||
metadata: { instanceAiPlannedTasks: graph },
|
||||
});
|
||||
|
||||
await storage.save('thread-1', graph);
|
||||
const loaded = await storage.get('thread-1');
|
||||
const loaded = await storage.get('thread-1');
|
||||
|
||||
expect(loaded?.tasks[0].id).toBe('task-1');
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.tasks.map((t) => t.kind)).toEqual(['build-workflow', 'checkpoint']);
|
||||
const checkpoint = loaded?.tasks.find((t) => t.id === 'verify-1');
|
||||
expect(checkpoint?.kind).toBe('checkpoint');
|
||||
expect(checkpoint?.deps).toEqual(['build-1']);
|
||||
});
|
||||
|
||||
it('returns null when the stored graph has an unknown kind', async () => {
|
||||
(memory.getThreadById as jest.Mock).mockResolvedValue({
|
||||
metadata: {
|
||||
instanceAiPlannedTasks: {
|
||||
...makeGraph(),
|
||||
tasks: [
|
||||
{
|
||||
id: 'x',
|
||||
title: 'x',
|
||||
kind: 'not-a-kind',
|
||||
spec: '',
|
||||
deps: [],
|
||||
status: 'planned',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const loaded = await storage.get('thread-1');
|
||||
expect(loaded).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update() kind parsing', () => {
|
||||
it('persists updates that include checkpoint kind', async () => {
|
||||
const graph = makeGraph();
|
||||
mockedPatchThread.mockImplementation(async (_mem, opts) => {
|
||||
await Promise.resolve();
|
||||
opts.update({
|
||||
metadata: { instanceAiPlannedTasks: graph },
|
||||
} as unknown as Parameters<typeof opts.update>[0]);
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await storage.update('thread-1', (g) => ({
|
||||
...g,
|
||||
tasks: g.tasks.map((t) => (t.id === 'verify-1' ? { ...t, status: 'running' as const } : t)),
|
||||
}));
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const checkpoint = result?.tasks.find((t) => t.id === 'verify-1');
|
||||
expect(checkpoint?.status).toBe('running');
|
||||
expect(checkpoint?.kind).toBe('checkpoint');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ const plannedTaskKindSchema = z.enum([
|
||||
'build-workflow',
|
||||
'manage-data-tables',
|
||||
'research',
|
||||
'checkpoint',
|
||||
]);
|
||||
|
||||
const plannedTaskStatusSchema = z.enum(['planned', 'running', 'succeeded', 'failed', 'cancelled']);
|
||||
@@ -36,7 +37,7 @@ const plannedTaskRecordSchema = z.object({
|
||||
const plannedTaskGraphSchema = z.object({
|
||||
planRunId: z.string(),
|
||||
messageGroupId: z.string().optional(),
|
||||
status: z.enum(['active', 'awaiting_replan', 'completed', 'cancelled']),
|
||||
status: z.enum(['awaiting_approval', 'active', 'awaiting_replan', 'completed', 'cancelled']),
|
||||
tasks: z.array(plannedTaskRecordSchema),
|
||||
});
|
||||
|
||||
|
||||
@@ -140,7 +140,9 @@ describe('data-tables tool', () => {
|
||||
noSuspendCtx(),
|
||||
);
|
||||
|
||||
expect(context.dataTableService.getSchema).toHaveBeenCalledWith('dt-1');
|
||||
expect(context.dataTableService.getSchema).toHaveBeenCalledWith('dt-1', {
|
||||
projectId: undefined,
|
||||
});
|
||||
expect(result).toEqual({ columns });
|
||||
});
|
||||
});
|
||||
@@ -168,6 +170,7 @@ describe('data-tables tool', () => {
|
||||
filter,
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
projectId: undefined,
|
||||
});
|
||||
expect(result).toEqual(queryResult);
|
||||
});
|
||||
@@ -399,7 +402,9 @@ describe('data-tables tool', () => {
|
||||
const tool = createDataTablesTool(context);
|
||||
const result = await tool.execute!(deleteInput as never, noSuspendCtx());
|
||||
|
||||
expect(context.dataTableService.delete).toHaveBeenCalledWith('dt-1');
|
||||
expect(context.dataTableService.delete).toHaveBeenCalledWith('dt-1', {
|
||||
projectId: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -409,7 +414,9 @@ describe('data-tables tool', () => {
|
||||
const tool = createDataTablesTool(context);
|
||||
const result = await tool.execute!(deleteInput as never, resumeCtx(true));
|
||||
|
||||
expect(context.dataTableService.delete).toHaveBeenCalledWith('dt-1');
|
||||
expect(context.dataTableService.delete).toHaveBeenCalledWith('dt-1', {
|
||||
projectId: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -469,10 +476,11 @@ describe('data-tables tool', () => {
|
||||
const tool = createDataTablesTool(context);
|
||||
const result = await tool.execute!(addColumnInput as never, noSuspendCtx());
|
||||
|
||||
expect(context.dataTableService.addColumn).toHaveBeenCalledWith('dt-1', {
|
||||
name: 'age',
|
||||
type: 'number',
|
||||
});
|
||||
expect(context.dataTableService.addColumn).toHaveBeenCalledWith(
|
||||
'dt-1',
|
||||
{ name: 'age', type: 'number' },
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({ column });
|
||||
});
|
||||
|
||||
@@ -542,7 +550,9 @@ describe('data-tables tool', () => {
|
||||
const tool = createDataTablesTool(context);
|
||||
const result = await tool.execute!(deleteColumnInput as never, noSuspendCtx());
|
||||
|
||||
expect(context.dataTableService.deleteColumn).toHaveBeenCalledWith('dt-1', 'col-1');
|
||||
expect(context.dataTableService.deleteColumn).toHaveBeenCalledWith('dt-1', 'col-1', {
|
||||
projectId: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -552,7 +562,9 @@ describe('data-tables tool', () => {
|
||||
const tool = createDataTablesTool(context);
|
||||
const result = await tool.execute!(deleteColumnInput as never, resumeCtx(true));
|
||||
|
||||
expect(context.dataTableService.deleteColumn).toHaveBeenCalledWith('dt-1', 'col-1');
|
||||
expect(context.dataTableService.deleteColumn).toHaveBeenCalledWith('dt-1', 'col-1', {
|
||||
projectId: undefined,
|
||||
});
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
@@ -614,6 +626,7 @@ describe('data-tables tool', () => {
|
||||
'dt-1',
|
||||
'col-1',
|
||||
'full_name',
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
@@ -628,6 +641,7 @@ describe('data-tables tool', () => {
|
||||
'dt-1',
|
||||
'col-1',
|
||||
'full_name',
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
@@ -689,6 +703,7 @@ describe('data-tables tool', () => {
|
||||
expect(context.dataTableService.insertRows).toHaveBeenCalledWith(
|
||||
'dt-1',
|
||||
insertRowsInput.rows,
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({ insertedCount: 2 });
|
||||
});
|
||||
@@ -703,6 +718,7 @@ describe('data-tables tool', () => {
|
||||
expect(context.dataTableService.insertRows).toHaveBeenCalledWith(
|
||||
'dt-1',
|
||||
insertRowsInput.rows,
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({ insertedCount: 2 });
|
||||
});
|
||||
@@ -789,6 +805,7 @@ describe('data-tables tool', () => {
|
||||
'dt-1',
|
||||
updateRowsInput.filter,
|
||||
updateRowsInput.data,
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({ updatedCount: 5 });
|
||||
});
|
||||
@@ -804,6 +821,7 @@ describe('data-tables tool', () => {
|
||||
'dt-1',
|
||||
updateRowsInput.filter,
|
||||
updateRowsInput.data,
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({ updatedCount: 3 });
|
||||
});
|
||||
@@ -900,6 +918,7 @@ describe('data-tables tool', () => {
|
||||
expect(context.dataTableService.deleteRows).toHaveBeenCalledWith(
|
||||
'dt-1',
|
||||
deleteRowsInput.filter,
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
@@ -925,6 +944,7 @@ describe('data-tables tool', () => {
|
||||
expect(context.dataTableService.deleteRows).toHaveBeenCalledWith(
|
||||
'dt-1',
|
||||
deleteRowsInput.filter,
|
||||
{ projectId: undefined },
|
||||
);
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
|
||||
@@ -279,6 +279,50 @@ describe('executions tool', () => {
|
||||
timeout: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
describe('allowedRunWorkflowIds scope', () => {
|
||||
it('runs without HITL when always_allow + workflow id is in the allow-list', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: { runWorkflow: 'always_allow' },
|
||||
allowedRunWorkflowIds: new Set(['wf-1']),
|
||||
});
|
||||
(context.executionService.run as jest.Mock).mockResolvedValue({
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
});
|
||||
const suspendFn = jest.fn();
|
||||
|
||||
const tool = createExecutionsTool(context);
|
||||
await tool.execute!(
|
||||
{ action: 'run' as const, workflowId: 'wf-1' },
|
||||
createAgentCtx({ suspend: suspendFn }) as never,
|
||||
);
|
||||
|
||||
expect(suspendFn).not.toHaveBeenCalled();
|
||||
expect(context.executionService.run).toHaveBeenCalledWith('wf-1', undefined, {
|
||||
timeout: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('still requires HITL approval when always_allow is set but workflow id is NOT in the allow-list', async () => {
|
||||
const context = createMockContext({
|
||||
permissions: { runWorkflow: 'always_allow' },
|
||||
allowedRunWorkflowIds: new Set(['wf-other']),
|
||||
});
|
||||
(context.workflowService.get as jest.Mock).mockResolvedValue({ name: 'Off-scope WF' });
|
||||
const suspendFn = jest.fn();
|
||||
|
||||
const tool = createExecutionsTool(context);
|
||||
const result = await tool.execute!(
|
||||
{ action: 'run' as const, workflowId: 'wf-1' },
|
||||
createAgentCtx({ suspend: suspendFn }) as never,
|
||||
);
|
||||
|
||||
expect(suspendFn).toHaveBeenCalled();
|
||||
expect(context.executionService.run).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({ denied: true, reason: 'Awaiting confirmation' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── debug ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -66,19 +66,32 @@ function isNameConflictError(error: unknown): boolean {
|
||||
|
||||
// ── Action schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
const projectIdDescribe =
|
||||
'Project ID. For list/create, scopes the operation to this project (defaults to personal). For id-based actions (schema, query, delete, add-column, delete-column, rename-column, insert/update/delete-rows), disambiguates when `dataTableId` is a name that exists in multiple accessible projects. Ignored when `dataTableId` is a UUID; rejected when the UUID belongs to a different project.';
|
||||
|
||||
const listAction = z.object({
|
||||
action: z.literal('list').describe('List data tables in a project'),
|
||||
projectId: z.string().optional().describe('Project ID. Defaults to personal project.'),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
});
|
||||
|
||||
const schemaAction = z.object({
|
||||
action: z.literal('schema').describe('Get column definitions for a data table'),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
});
|
||||
|
||||
const queryAction = z.object({
|
||||
action: z.literal('query').describe('Query rows from a data table with optional filtering'),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
filter: filterSchema.optional().describe('Row filter conditions'),
|
||||
limit: z
|
||||
.number()
|
||||
@@ -93,7 +106,7 @@ const queryAction = z.object({
|
||||
const createAction = z.object({
|
||||
action: z.literal('create').describe('Create a new data table with typed columns'),
|
||||
name: z.string().min(1).max(128).describe('Table name'),
|
||||
projectId: z.string().optional().describe('Project ID. Defaults to personal project.'),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
columns: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -107,32 +120,57 @@ const createAction = z.object({
|
||||
|
||||
const deleteAction = z.object({
|
||||
action: z.literal('delete').describe('Permanently delete a data table and all its rows'),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
});
|
||||
|
||||
const addColumnAction = z.object({
|
||||
action: z.literal('add-column').describe('Add a new column to an existing data table'),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
columnName: z.string().describe('Column name (alphanumeric + underscores)'),
|
||||
type: columnTypeSchema.describe('Column data type'),
|
||||
});
|
||||
|
||||
const deleteColumnAction = z.object({
|
||||
action: z.literal('delete-column').describe('Remove a column from a data table'),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
columnId: z.string().describe('ID of the column'),
|
||||
});
|
||||
|
||||
const renameColumnAction = z.object({
|
||||
action: z.literal('rename-column').describe('Rename a column in a data table'),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
columnId: z.string().describe('ID of the column'),
|
||||
newName: z.string().describe('New column name'),
|
||||
});
|
||||
|
||||
const insertRowsAction = z.object({
|
||||
action: z.literal('insert-rows').describe('Insert rows into a data table'),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
rows: z
|
||||
.array(z.record(z.unknown()))
|
||||
.min(1)
|
||||
@@ -142,7 +180,12 @@ const insertRowsAction = z.object({
|
||||
|
||||
const updateRowsAction = z.object({
|
||||
action: z.literal('update-rows').describe('Update rows matching a filter in a data table'),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
filter: filterSchema.describe('Row filter conditions'),
|
||||
data: z.record(z.unknown()).describe('Column values to set on matching rows'),
|
||||
});
|
||||
@@ -153,7 +196,12 @@ const deleteRowsAction = z.object({
|
||||
.describe(
|
||||
'Delete rows matching a filter from a data table. At least one filter condition is required.',
|
||||
),
|
||||
dataTableId: z.string().describe('ID of the data table'),
|
||||
dataTableId: z
|
||||
.string()
|
||||
.describe(
|
||||
'ID (UUID) of the data table. A name also works as a fallback, but pass an id when possible.',
|
||||
),
|
||||
projectId: z.string().optional().describe(projectIdDescribe),
|
||||
filter: filterSchemaWithMinOne.describe('Row filter conditions'),
|
||||
});
|
||||
|
||||
@@ -190,7 +238,9 @@ async function handleSchema(
|
||||
context: InstanceAiContext,
|
||||
input: Extract<FullInput, { action: 'schema' }>,
|
||||
) {
|
||||
const columns = await context.dataTableService.getSchema(input.dataTableId);
|
||||
const columns = await context.dataTableService.getSchema(input.dataTableId, {
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return { columns };
|
||||
}
|
||||
|
||||
@@ -202,6 +252,7 @@ async function handleQuery(
|
||||
filter: input.filter,
|
||||
limit: input.limit,
|
||||
offset: input.offset,
|
||||
projectId: input.projectId,
|
||||
});
|
||||
|
||||
const returnedRows = result.data.length;
|
||||
@@ -301,7 +352,7 @@ async function handleDelete(
|
||||
}
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
await context.dataTableService.delete(input.dataTableId);
|
||||
await context.dataTableService.delete(input.dataTableId, { projectId: input.projectId });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -335,10 +386,11 @@ async function handleAddColumn(
|
||||
}
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
const column = await context.dataTableService.addColumn(input.dataTableId, {
|
||||
name: input.columnName,
|
||||
type: input.type,
|
||||
});
|
||||
const column = await context.dataTableService.addColumn(
|
||||
input.dataTableId,
|
||||
{ name: input.columnName, type: input.type },
|
||||
{ projectId: input.projectId },
|
||||
);
|
||||
return { column };
|
||||
}
|
||||
|
||||
@@ -372,7 +424,9 @@ async function handleDeleteColumn(
|
||||
}
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
await context.dataTableService.deleteColumn(input.dataTableId, input.columnId);
|
||||
await context.dataTableService.deleteColumn(input.dataTableId, input.columnId, {
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -406,7 +460,9 @@ async function handleRenameColumn(
|
||||
}
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
await context.dataTableService.renameColumn(input.dataTableId, input.columnId, input.newName);
|
||||
await context.dataTableService.renameColumn(input.dataTableId, input.columnId, input.newName, {
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -440,7 +496,9 @@ async function handleInsertRows(
|
||||
}
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
return await context.dataTableService.insertRows(input.dataTableId, input.rows);
|
||||
return await context.dataTableService.insertRows(input.dataTableId, input.rows, {
|
||||
projectId: input.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleUpdateRows(
|
||||
@@ -473,7 +531,9 @@ async function handleUpdateRows(
|
||||
}
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
return await context.dataTableService.updateRows(input.dataTableId, input.filter, input.data);
|
||||
return await context.dataTableService.updateRows(input.dataTableId, input.filter, input.data, {
|
||||
projectId: input.projectId,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleDeleteRows(
|
||||
@@ -515,7 +575,9 @@ async function handleDeleteRows(
|
||||
}
|
||||
|
||||
// State 3: Approved or always_allow — execute
|
||||
const result = await context.dataTableService.deleteRows(input.dataTableId, input.filter);
|
||||
const result = await context.dataTableService.deleteRows(input.dataTableId, input.filter, {
|
||||
projectId: input.projectId,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: result.deletedCount,
|
||||
|
||||
@@ -139,7 +139,15 @@ async function handleRun(
|
||||
};
|
||||
}
|
||||
|
||||
const needsApproval = context.permissions?.runWorkflow !== 'always_allow';
|
||||
// `always_allow` is only honored for the workflow IDs the caller pre-authorized
|
||||
// (e.g. checkpoint follow-ups scope the override to the workflows the checkpoint
|
||||
// is verifying). When the allow-list is unset, `always_allow` applies broadly,
|
||||
// matching the legacy behavior.
|
||||
const allowList = context.allowedRunWorkflowIds;
|
||||
const allowedByScope =
|
||||
context.permissions?.runWorkflow === 'always_allow' &&
|
||||
(allowList === undefined || allowList.has(input.workflowId));
|
||||
const needsApproval = !allowedByScope;
|
||||
|
||||
// If approval is required and this is the first call, suspend for confirmation
|
||||
if (needsApproval && (resumeData === undefined || resumeData === null)) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createToolsFromLocalMcpServer } from './filesystem/create-tools-from-mc
|
||||
import { createNodesTool } from './nodes.tool';
|
||||
import { createBrowserCredentialSetupTool } from './orchestration/browser-credential-setup.tool';
|
||||
import { createBuildWorkflowAgentTool } from './orchestration/build-workflow-agent.tool';
|
||||
import { createCompleteCheckpointTool } from './orchestration/complete-checkpoint.tool';
|
||||
import { createDelegateTool } from './orchestration/delegate.tool';
|
||||
import { createPlanWithAgentTool } from './orchestration/plan-with-agent.tool';
|
||||
import { createPlanTool } from './orchestration/plan.tool';
|
||||
@@ -75,6 +76,7 @@ export function createOrchestrationTools(context: OrchestrationContext) {
|
||||
'task-control': createTaskControlTool(context),
|
||||
delegate: createDelegateTool(context),
|
||||
'build-workflow-with-agent': createBuildWorkflowAgentTool(context),
|
||||
'complete-checkpoint': createCompleteCheckpointTool(context),
|
||||
...(context.browserMcpConfig || hasGatewayBrowserTools(context)
|
||||
? {
|
||||
'browser-credential-setup': createBrowserCredentialSetupTool(context),
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
import { BlueprintAccumulator } from '../blueprint-accumulator';
|
||||
|
||||
describe('BlueprintAccumulator', () => {
|
||||
let accumulator: BlueprintAccumulator;
|
||||
|
||||
beforeEach(() => {
|
||||
accumulator = new BlueprintAccumulator();
|
||||
});
|
||||
|
||||
describe('addItem with kind=checkpoint', () => {
|
||||
it('produces a PlannedTaskInput with kind=checkpoint', () => {
|
||||
accumulator.addItem({
|
||||
kind: 'workflow',
|
||||
id: 'wf-1',
|
||||
name: 'Daily Email',
|
||||
purpose: 'Send a daily summary email',
|
||||
integrations: [],
|
||||
dependsOn: [],
|
||||
});
|
||||
|
||||
const task = accumulator.addItem({
|
||||
kind: 'checkpoint',
|
||||
id: 'verify-1',
|
||||
title: "Verify 'Daily Email' workflow runs without errors",
|
||||
instructions:
|
||||
'Call verify-built-workflow with the workItemId from wf-1. Assert success===true.',
|
||||
dependsOn: ['wf-1'],
|
||||
});
|
||||
|
||||
expect(task).toEqual({
|
||||
id: 'verify-1',
|
||||
title: "Verify 'Daily Email' workflow runs without errors",
|
||||
kind: 'checkpoint',
|
||||
spec: 'Call verify-built-workflow with the workItemId from wf-1. Assert success===true.',
|
||||
deps: ['wf-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('includes the checkpoint in getTaskList and getTaskItemsForEvent', () => {
|
||||
accumulator.addItem({
|
||||
kind: 'workflow',
|
||||
id: 'wf-1',
|
||||
name: 'WF',
|
||||
purpose: 'p',
|
||||
integrations: [],
|
||||
dependsOn: [],
|
||||
});
|
||||
accumulator.addItem({
|
||||
kind: 'checkpoint',
|
||||
id: 'verify-1',
|
||||
title: 'Verify WF',
|
||||
instructions: 'Verify it',
|
||||
dependsOn: ['wf-1'],
|
||||
});
|
||||
|
||||
const list = accumulator.getTaskList();
|
||||
const items = accumulator.getTaskItemsForEvent();
|
||||
|
||||
expect(list.map((t) => t.kind)).toEqual(['build-workflow', 'checkpoint']);
|
||||
expect(items.map((t) => t.id)).toEqual(['wf-1', 'verify-1']);
|
||||
});
|
||||
|
||||
it('removeItem drops the checkpoint and dangling dep references', () => {
|
||||
accumulator.addItem({
|
||||
kind: 'workflow',
|
||||
id: 'wf-1',
|
||||
name: 'WF',
|
||||
purpose: 'p',
|
||||
integrations: [],
|
||||
dependsOn: [],
|
||||
});
|
||||
accumulator.addItem({
|
||||
kind: 'checkpoint',
|
||||
id: 'verify-1',
|
||||
title: 'Verify WF',
|
||||
instructions: 'Verify it',
|
||||
dependsOn: ['wf-1'],
|
||||
});
|
||||
|
||||
expect(accumulator.removeItem('verify-1')).toBe(true);
|
||||
expect(accumulator.getTaskList().map((t) => t.id)).toEqual(['wf-1']);
|
||||
});
|
||||
|
||||
it('cascade-removes a checkpoint when its only build-workflow dep is removed', () => {
|
||||
accumulator.addItem({
|
||||
kind: 'workflow',
|
||||
id: 'wf-1',
|
||||
name: 'WF',
|
||||
purpose: 'p',
|
||||
integrations: [],
|
||||
dependsOn: [],
|
||||
});
|
||||
accumulator.addItem({
|
||||
kind: 'checkpoint',
|
||||
id: 'verify-1',
|
||||
title: 'Verify WF',
|
||||
instructions: 'Verify it',
|
||||
dependsOn: ['wf-1'],
|
||||
});
|
||||
|
||||
accumulator.removeItem('wf-1');
|
||||
|
||||
// Checkpoint must not survive as an orphan — createPlan would reject
|
||||
// it at submit-plan time because checkpoint deps must reference a
|
||||
// build-workflow task.
|
||||
const list = accumulator.getTaskList();
|
||||
expect(list.find((t) => t.id === 'verify-1')).toBeUndefined();
|
||||
expect(list.find((t) => t.id === 'wf-1')).toBeUndefined();
|
||||
expect(list).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps a checkpoint that still depends on at least one remaining workflow', () => {
|
||||
accumulator.addItem({
|
||||
kind: 'workflow',
|
||||
id: 'wf-1',
|
||||
name: 'WF1',
|
||||
purpose: 'p',
|
||||
integrations: [],
|
||||
dependsOn: [],
|
||||
});
|
||||
accumulator.addItem({
|
||||
kind: 'workflow',
|
||||
id: 'wf-2',
|
||||
name: 'WF2',
|
||||
purpose: 'p',
|
||||
integrations: [],
|
||||
dependsOn: [],
|
||||
});
|
||||
accumulator.addItem({
|
||||
kind: 'checkpoint',
|
||||
id: 'verify-both',
|
||||
title: 'Verify both',
|
||||
instructions: 'Verify both',
|
||||
dependsOn: ['wf-1', 'wf-2'],
|
||||
});
|
||||
|
||||
accumulator.removeItem('wf-1');
|
||||
|
||||
const checkpoint = accumulator.getTaskList().find((t) => t.id === 'verify-both');
|
||||
expect(checkpoint).toBeDefined();
|
||||
expect(checkpoint?.deps).toEqual(['wf-2']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { blueprintCheckpointItemSchema } from '../blueprint.schema';
|
||||
|
||||
describe('blueprintCheckpointItemSchema', () => {
|
||||
it('rejects a checkpoint with an empty dependsOn array', () => {
|
||||
const result = blueprintCheckpointItemSchema.safeParse({
|
||||
id: 'verify-1',
|
||||
title: 'Verify something',
|
||||
instructions: 'Call verify-built-workflow',
|
||||
dependsOn: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a checkpoint that omits dependsOn entirely', () => {
|
||||
const result = blueprintCheckpointItemSchema.safeParse({
|
||||
id: 'verify-1',
|
||||
title: 'Verify something',
|
||||
instructions: 'Call verify-built-workflow',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a checkpoint that depends on at least one workflow item', () => {
|
||||
const result = blueprintCheckpointItemSchema.safeParse({
|
||||
id: 'verify-1',
|
||||
title: 'Verify Daily Email workflow runs without errors',
|
||||
instructions: 'Call verify-built-workflow with the workItemId from wf-1.',
|
||||
dependsOn: ['wf-1'],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
+142
-1
@@ -5,13 +5,45 @@ jest.mock('@mastra/core/agent', () => ({
|
||||
jest.mock('@mastra/core/mastra', () => ({
|
||||
Mastra: jest.fn(),
|
||||
}));
|
||||
jest.mock('@mastra/core/tools', () => ({
|
||||
createTool: jest.fn((config: Record<string, unknown>) => config),
|
||||
}));
|
||||
|
||||
import type { OrchestrationContext } from '../../../types';
|
||||
import type { SubmitWorkflowAttempt } from '../../workflows/submit-workflow.tool';
|
||||
|
||||
const { recordSuccessfulWorkflowBuilds, resultFromPostStreamError } =
|
||||
const { resultFromPostStreamError, createBuildWorkflowAgentTool, recordSuccessfulWorkflowBuilds } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../build-workflow-agent.tool') as typeof import('../build-workflow-agent.tool');
|
||||
|
||||
type BuildExecutable = {
|
||||
execute: (input: Record<string, unknown>) => Promise<{ result: string; taskId: string }>;
|
||||
};
|
||||
|
||||
function createMockContext(overrides: Partial<OrchestrationContext> = {}): OrchestrationContext {
|
||||
return {
|
||||
threadId: 'test-thread',
|
||||
runId: 'test-run',
|
||||
userId: 'test-user',
|
||||
orchestratorAgentId: 'test-agent',
|
||||
modelId: 'test-model' as OrchestrationContext['modelId'],
|
||||
storage: { id: 'test-storage' } as OrchestrationContext['storage'],
|
||||
subAgentMaxSteps: 5,
|
||||
eventBus: {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getEventsAfter: jest.fn(),
|
||||
getNextEventId: jest.fn(),
|
||||
getEventsForRun: jest.fn().mockReturnValue([]),
|
||||
getEventsForRuns: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
domainTools: {},
|
||||
abortSignal: new AbortController().signal,
|
||||
...overrides,
|
||||
} as OrchestrationContext;
|
||||
}
|
||||
|
||||
const MAIN_PATH = '/home/daytona/workspace/src/workflow.ts';
|
||||
|
||||
describe('resultFromPostStreamError', () => {
|
||||
@@ -129,6 +161,115 @@ describe('resultFromPostStreamError', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createBuildWorkflowAgentTool — plan-enforcement guard', () => {
|
||||
const ORIGINAL_ENV = process.env.N8N_INSTANCE_AI_ENFORCE_BUILD_VIA_PLAN;
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_ENV === undefined) {
|
||||
delete process.env.N8N_INSTANCE_AI_ENFORCE_BUILD_VIA_PLAN;
|
||||
} else {
|
||||
process.env.N8N_INSTANCE_AI_ENFORCE_BUILD_VIA_PLAN = ORIGINAL_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects direct calls outside a replan/checkpoint follow-up', async () => {
|
||||
const context = createMockContext();
|
||||
const tool = createBuildWorkflowAgentTool(context) as unknown as BuildExecutable;
|
||||
|
||||
const out = await tool.execute({ task: 'Build a Slack notifier' });
|
||||
|
||||
expect(out.taskId).toBe('');
|
||||
expect(out.result).toContain('bypassPlan');
|
||||
expect(out.result).toMatch(
|
||||
/For new workflows, multi-workflow builds, or data-table schema changes/,
|
||||
);
|
||||
expect(out.result).toContain('`plan`');
|
||||
expect(context.logger.warn).toHaveBeenCalledWith(
|
||||
'build-workflow-with-agent called outside plan/replan context — rejecting',
|
||||
expect.objectContaining({ threadId: 'test-thread' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects bypassPlan=true without a workflowId (new builds must go through plan)', async () => {
|
||||
const context = createMockContext();
|
||||
const tool = createBuildWorkflowAgentTool(context) as unknown as BuildExecutable;
|
||||
|
||||
const out = await tool.execute({
|
||||
task: 'build something shiny',
|
||||
bypassPlan: true,
|
||||
reason: 'I feel like skipping the plan today',
|
||||
});
|
||||
|
||||
expect(out.taskId).toBe('');
|
||||
expect(out.result).toMatch(/edits to an EXISTING workflow and requires a `workflowId`/);
|
||||
});
|
||||
|
||||
it('rejects bypassPlan=true without a reason', async () => {
|
||||
const context = createMockContext();
|
||||
const tool = createBuildWorkflowAgentTool(context) as unknown as BuildExecutable;
|
||||
|
||||
const out = await tool.execute({
|
||||
task: 'patch one expression',
|
||||
workflowId: 'WF_EXISTING',
|
||||
bypassPlan: true,
|
||||
});
|
||||
|
||||
expect(out.taskId).toBe('');
|
||||
expect(out.result).toContain('requires a one-sentence `reason`');
|
||||
});
|
||||
|
||||
it('allows the call when bypassPlan=true with a reason is provided', async () => {
|
||||
const context = createMockContext();
|
||||
const tool = createBuildWorkflowAgentTool(context) as unknown as BuildExecutable;
|
||||
|
||||
const out = await tool.execute({
|
||||
task: 'patch one expression',
|
||||
workflowId: 'WF_EXISTING',
|
||||
bypassPlan: true,
|
||||
reason: 'Swap Slack channel on this notifier.',
|
||||
});
|
||||
|
||||
// Guard passes → reaches startBuildWorkflowAgentTask, which short-circuits on
|
||||
// missing spawnBackgroundTask. The point is we got past the guard, not what
|
||||
// the downstream does.
|
||||
expect(out.result).not.toMatch(/`bypassPlan: true` is for edits/);
|
||||
const warnMock = context.logger.warn as jest.Mock<void, [string, Record<string, unknown>?]>;
|
||||
expect(warnMock.mock.calls.some((c) => c[0].includes('bypassing plan'))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows direct calls in a replan follow-up', async () => {
|
||||
const context = createMockContext({ isReplanFollowUp: true });
|
||||
const tool = createBuildWorkflowAgentTool(context) as unknown as BuildExecutable;
|
||||
|
||||
const out = await tool.execute({ task: 'retry after failure' });
|
||||
|
||||
expect(out.result).not.toContain('direct builder calls require');
|
||||
expect(context.logger.warn).not.toHaveBeenCalledWith(
|
||||
'build-workflow-with-agent called outside plan/replan context — rejecting',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows direct calls in a checkpoint follow-up', async () => {
|
||||
const context = createMockContext({ isCheckpointFollowUp: true });
|
||||
const tool = createBuildWorkflowAgentTool(context) as unknown as BuildExecutable;
|
||||
|
||||
const out = await tool.execute({ task: 'checkpoint branch' });
|
||||
|
||||
expect(out.result).not.toContain('direct builder calls require');
|
||||
});
|
||||
|
||||
it('skips the guard when the env flag is disabled', async () => {
|
||||
process.env.N8N_INSTANCE_AI_ENFORCE_BUILD_VIA_PLAN = 'false';
|
||||
const context = createMockContext();
|
||||
const tool = createBuildWorkflowAgentTool(context) as unknown as BuildExecutable;
|
||||
|
||||
const out = await tool.execute({ task: 'build directly' });
|
||||
|
||||
expect(out.result).not.toContain('direct builder calls require');
|
||||
});
|
||||
});
|
||||
|
||||
describe('recordSuccessfulWorkflowBuilds', () => {
|
||||
it('records workflow IDs returned from successful build-workflow executions', async () => {
|
||||
const onWorkflowId = jest.fn();
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
import type {
|
||||
CheckpointSettleResult,
|
||||
OrchestrationContext,
|
||||
PlannedTaskService,
|
||||
} from '../../../types';
|
||||
|
||||
// Mock heavy Mastra dependencies to avoid ESM issues in Jest
|
||||
jest.mock('@mastra/core/tools', () => ({
|
||||
createTool: jest.fn((config: Record<string, unknown>) => config),
|
||||
}));
|
||||
|
||||
const { createCompleteCheckpointTool } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../complete-checkpoint.tool') as typeof import('../complete-checkpoint.tool');
|
||||
|
||||
type Executable = {
|
||||
execute: (input: unknown) => Promise<{ ok: boolean; result: string }>;
|
||||
};
|
||||
|
||||
function makeService(overrides: Partial<PlannedTaskService> = {}): PlannedTaskService {
|
||||
return {
|
||||
markCheckpointSucceeded: jest.fn(),
|
||||
markCheckpointFailed: jest.fn(),
|
||||
...overrides,
|
||||
} as unknown as PlannedTaskService;
|
||||
}
|
||||
|
||||
function makeContext(service: PlannedTaskService): OrchestrationContext {
|
||||
return {
|
||||
threadId: 'thread-1',
|
||||
runId: 'run-1',
|
||||
userId: 'user-1',
|
||||
orchestratorAgentId: 'orc',
|
||||
modelId: 'model' as OrchestrationContext['modelId'],
|
||||
storage: {} as OrchestrationContext['storage'],
|
||||
subAgentMaxSteps: 5,
|
||||
eventBus: {
|
||||
publish: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
getEventsAfter: jest.fn(),
|
||||
getNextEventId: jest.fn(),
|
||||
getEventsForRun: jest.fn().mockReturnValue([]),
|
||||
getEventsForRuns: jest.fn().mockReturnValue([]),
|
||||
},
|
||||
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn() },
|
||||
domainTools: {},
|
||||
abortSignal: new AbortController().signal,
|
||||
taskStorage: { get: jest.fn(), save: jest.fn() },
|
||||
plannedTaskService: service,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createCompleteCheckpointTool', () => {
|
||||
it('marks a checkpoint succeeded via markCheckpointSucceeded', async () => {
|
||||
const service = makeService({
|
||||
markCheckpointSucceeded: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service)) as unknown as Executable;
|
||||
|
||||
const res = await tool.execute({
|
||||
taskId: 'verify-1',
|
||||
status: 'succeeded',
|
||||
result: 'Verified',
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.result).toContain('succeeded');
|
||||
expect(service.markCheckpointSucceeded).toHaveBeenCalledWith('thread-1', 'verify-1', {
|
||||
result: 'Verified',
|
||||
outcome: undefined,
|
||||
});
|
||||
expect(service.markCheckpointFailed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks a checkpoint failed via markCheckpointFailed', async () => {
|
||||
const service = makeService({
|
||||
markCheckpointFailed: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service)) as unknown as Executable;
|
||||
|
||||
const res = await tool.execute({
|
||||
taskId: 'verify-1',
|
||||
status: 'failed',
|
||||
error: 'Workflow errored',
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(service.markCheckpointFailed).toHaveBeenCalledWith('thread-1', 'verify-1', {
|
||||
error: 'Workflow errored',
|
||||
outcome: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards structured outcome to markCheckpointFailed so replans keep execution context', async () => {
|
||||
const service = makeService({
|
||||
markCheckpointFailed: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service)) as unknown as Executable;
|
||||
|
||||
await tool.execute({
|
||||
taskId: 'verify-1',
|
||||
status: 'failed',
|
||||
error: 'Node crashed',
|
||||
outcome: {
|
||||
executionId: 'exec-42',
|
||||
failureNode: 'Insert Row',
|
||||
errorMessage: 'constraint violation',
|
||||
},
|
||||
});
|
||||
|
||||
expect(service.markCheckpointFailed).toHaveBeenCalledWith('thread-1', 'verify-1', {
|
||||
error: 'Node crashed',
|
||||
outcome: {
|
||||
executionId: 'exec-42',
|
||||
failureNode: 'Insert Row',
|
||||
errorMessage: 'constraint violation',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error string (not throw) on not-found', async () => {
|
||||
const not: CheckpointSettleResult = { ok: false, reason: 'not-found' };
|
||||
const service = makeService({
|
||||
markCheckpointSucceeded: jest.fn().mockResolvedValue(not),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service)) as unknown as Executable;
|
||||
|
||||
const res = await tool.execute({ taskId: 'missing', status: 'succeeded' });
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.result).toContain('no task with id');
|
||||
});
|
||||
|
||||
it('returns error string on wrong-kind', async () => {
|
||||
const wk: CheckpointSettleResult = {
|
||||
ok: false,
|
||||
reason: 'wrong-kind',
|
||||
actual: { kind: 'build-workflow' },
|
||||
};
|
||||
const service = makeService({
|
||||
markCheckpointSucceeded: jest.fn().mockResolvedValue(wk),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service)) as unknown as Executable;
|
||||
|
||||
const res = await tool.execute({ taskId: 'wf-1', status: 'succeeded' });
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.result).toContain('not a checkpoint');
|
||||
expect(res.result).toContain('build-workflow');
|
||||
});
|
||||
|
||||
it('returns error string on wrong-status', async () => {
|
||||
const ws: CheckpointSettleResult = {
|
||||
ok: false,
|
||||
reason: 'wrong-status',
|
||||
actual: { status: 'planned' },
|
||||
};
|
||||
const service = makeService({
|
||||
markCheckpointSucceeded: jest.fn().mockResolvedValue(ws),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service)) as unknown as Executable;
|
||||
|
||||
const res = await tool.execute({ taskId: 'verify-1', status: 'succeeded' });
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.result).toContain('not in running state');
|
||||
expect(res.result).toContain('planned');
|
||||
});
|
||||
|
||||
it('returns an error when planned task service is absent', async () => {
|
||||
const tool = createCompleteCheckpointTool({
|
||||
...makeContext(makeService()),
|
||||
plannedTaskService: undefined,
|
||||
} as OrchestrationContext) as unknown as Executable;
|
||||
|
||||
const res = await tool.execute({ taskId: 'verify-1', status: 'succeeded' });
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.result).toContain('not available');
|
||||
});
|
||||
|
||||
it('defaults failed-error to result or a sensible default', async () => {
|
||||
const service = makeService({
|
||||
markCheckpointFailed: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, graph: { tasks: [], planRunId: 'r', status: 'active' } }),
|
||||
});
|
||||
const tool = createCompleteCheckpointTool(makeContext(service)) as unknown as Executable;
|
||||
|
||||
await tool.execute({
|
||||
taskId: 'verify-1',
|
||||
status: 'failed',
|
||||
result: 'Workflow hit 429 during verify',
|
||||
});
|
||||
|
||||
expect(service.markCheckpointFailed).toHaveBeenCalledWith('thread-1', 'verify-1', {
|
||||
error: 'Workflow hit 429 during verify',
|
||||
outcome: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
// Mock heavy Mastra dependencies to avoid ESM issues in Jest
|
||||
jest.mock('@mastra/core/agent', () => ({
|
||||
Agent: jest.fn(),
|
||||
}));
|
||||
jest.mock('@mastra/core/mastra', () => ({
|
||||
Mastra: jest.fn(),
|
||||
}));
|
||||
jest.mock('@mastra/core/tools', () => ({
|
||||
createTool: jest.fn((config: Record<string, unknown>) => config),
|
||||
}));
|
||||
|
||||
import type { OrchestrationContext, PlannedTaskGraph, PlannedTaskService } from '../../../types';
|
||||
|
||||
const { __testClearPlannedTaskGraph, __testFormatMessagesForBriefing } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../plan-with-agent.tool') as typeof import('../plan-with-agent.tool');
|
||||
|
||||
function makeContext(overrides: {
|
||||
graph: PlannedTaskGraph | null;
|
||||
runId?: string;
|
||||
}): {
|
||||
context: OrchestrationContext;
|
||||
clear: jest.Mock;
|
||||
getGraph: jest.Mock;
|
||||
} {
|
||||
const clear = jest.fn(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
const getGraph = jest.fn(async () => {
|
||||
await Promise.resolve();
|
||||
return overrides.graph;
|
||||
});
|
||||
const plannedTaskService: Partial<PlannedTaskService> = {
|
||||
getGraph,
|
||||
clear,
|
||||
};
|
||||
const context = {
|
||||
threadId: 't-1',
|
||||
runId: overrides.runId ?? 'run-current',
|
||||
plannedTaskService: plannedTaskService as PlannedTaskService,
|
||||
} as unknown as OrchestrationContext;
|
||||
return { context, clear, getGraph };
|
||||
}
|
||||
|
||||
describe('clearPlannedTaskGraph', () => {
|
||||
it('clears the graph when it belongs to this run and is awaiting approval', async () => {
|
||||
const { context, clear } = makeContext({
|
||||
graph: {
|
||||
planRunId: 'run-current',
|
||||
status: 'awaiting_approval',
|
||||
tasks: [],
|
||||
},
|
||||
});
|
||||
|
||||
await __testClearPlannedTaskGraph(context);
|
||||
|
||||
expect(clear).toHaveBeenCalledWith('t-1');
|
||||
});
|
||||
|
||||
it('does not clear an active graph from a prior approved plan', async () => {
|
||||
// A previous `/plan` call already succeeded; its graph is `active` with
|
||||
// pending checkpoints. A new planner error must not wipe it.
|
||||
const { context, clear } = makeContext({
|
||||
graph: {
|
||||
planRunId: 'run-previous',
|
||||
status: 'active',
|
||||
tasks: [],
|
||||
},
|
||||
});
|
||||
|
||||
await __testClearPlannedTaskGraph(context);
|
||||
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not clear an awaiting-approval graph that was created by a different planner run', async () => {
|
||||
// Defensive: a concurrent plan for a different run should not have its
|
||||
// unapproved graph wiped by this run's error-path cleanup.
|
||||
const { context, clear } = makeContext({
|
||||
graph: {
|
||||
planRunId: 'run-other',
|
||||
status: 'awaiting_approval',
|
||||
tasks: [],
|
||||
},
|
||||
});
|
||||
|
||||
await __testClearPlannedTaskGraph(context);
|
||||
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when no graph exists', async () => {
|
||||
const { context, clear, getGraph } = makeContext({ graph: null });
|
||||
|
||||
await __testClearPlannedTaskGraph(context);
|
||||
|
||||
expect(getGraph).toHaveBeenCalled();
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows getGraph errors so the caller can return its own error', async () => {
|
||||
const { context, getGraph } = makeContext({
|
||||
graph: { planRunId: 'run-current', status: 'awaiting_approval', tasks: [] },
|
||||
});
|
||||
getGraph.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(__testClearPlannedTaskGraph(context)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatMessagesForBriefing', () => {
|
||||
// The planner system prompt (plan-agent-prompt.ts) treats <current-datetime>
|
||||
// and <user-timezone> as a paired contract — schedule/cron decisions read
|
||||
// both. Emitting only one drops half the contract.
|
||||
|
||||
it('emits <current-datetime> alongside <user-timezone> when a zone is provided', () => {
|
||||
const briefing = __testFormatMessagesForBriefing(
|
||||
[{ role: 'user', content: 'schedule me a daily digest' }],
|
||||
undefined,
|
||||
'America/New_York',
|
||||
);
|
||||
|
||||
expect(briefing).toMatch(/<current-datetime>[^<]+<\/current-datetime>/);
|
||||
expect(briefing).toContain('<user-timezone>America/New_York</user-timezone>');
|
||||
});
|
||||
|
||||
it('still emits <current-datetime> when no zone is provided', () => {
|
||||
const briefing = __testFormatMessagesForBriefing([], undefined, undefined);
|
||||
|
||||
expect(briefing).toMatch(/<current-datetime>[^<]+<\/current-datetime>/);
|
||||
expect(briefing).not.toContain('<user-timezone>');
|
||||
});
|
||||
});
|
||||
@@ -20,6 +20,8 @@ function makePlannedTaskService(overrides: Partial<PlannedTaskService> = {}): Pl
|
||||
return {
|
||||
createPlan: jest.fn().mockResolvedValue(undefined),
|
||||
getGraph: jest.fn().mockResolvedValue(null),
|
||||
approvePlan: jest.fn().mockResolvedValue(undefined),
|
||||
clear: jest.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
} as unknown as PlannedTaskService;
|
||||
}
|
||||
@@ -230,4 +232,120 @@ describe('createPlanTool — replan-only guard', () => {
|
||||
expect(out.result).toContain('Plan approved');
|
||||
expect(context.schedulePlannedTasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('flips graph to active via approvePlan before scheduling on approval', async () => {
|
||||
const context = createMockContext({ currentUserMessage: 'ordinary message' });
|
||||
const tool = createPlanTool(context) as unknown as Executable;
|
||||
|
||||
await tool.execute({ tasks: validTasks() }, { agent: { resumeData: { approved: true } } });
|
||||
|
||||
expect(context.plannedTaskService!.approvePlan).toHaveBeenCalledWith('test-thread');
|
||||
expect(context.schedulePlannedTasks).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns the rejection result even when taskStorage.save fails so the revision flow can proceed', async () => {
|
||||
// The persisted graph stays in awaiting_approval regardless of UI cleanup
|
||||
// — the next createPlan overwrites it. A storage flake here must not abort
|
||||
// the rejection path or strand the user without a "User requested changes"
|
||||
// message and a chance to revise.
|
||||
const context = createMockContext({
|
||||
currentUserMessage: 'ordinary message',
|
||||
taskStorage: {
|
||||
get: jest.fn(),
|
||||
save: jest.fn().mockRejectedValue(new Error('storage flake')),
|
||||
} as TaskStorage,
|
||||
});
|
||||
const tool = createPlanTool(context) as unknown as Executable;
|
||||
|
||||
const out = await tool.execute(
|
||||
{ tasks: validTasks() },
|
||||
{ agent: { resumeData: { approved: false, userInput: 'try again' } } },
|
||||
);
|
||||
|
||||
expect(out.taskCount).toBe(0);
|
||||
expect(out.result).toContain('User requested changes');
|
||||
expect(context.logger.warn).toHaveBeenCalledWith(
|
||||
'Failed to clear rejected plan checklist',
|
||||
expect.objectContaining({ error: expect.anything() as unknown }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the awaiting_approval graph on rejection so a same-turn revision can pass the guard', async () => {
|
||||
// The rejected plan stays in `awaiting_approval` (scoped to runId) so the
|
||||
// LLM's next create-tasks call — which the tool result tells it to make —
|
||||
// is treated as a revision and bypasses planner-discovery guard. The
|
||||
// scheduler ignores `awaiting_approval`, so leaving it in place can't
|
||||
// dispatch a rejected plan.
|
||||
const context = createMockContext({ currentUserMessage: 'ordinary message' });
|
||||
const tool = createPlanTool(context) as unknown as Executable;
|
||||
|
||||
const out = await tool.execute(
|
||||
{ tasks: validTasks() },
|
||||
{ agent: { resumeData: { approved: false, userInput: 'not what I wanted' } } },
|
||||
);
|
||||
|
||||
expect(out.taskCount).toBe(0);
|
||||
expect(out.result).toContain('User requested changes');
|
||||
expect(context.plannedTaskService!.clear).not.toHaveBeenCalled();
|
||||
expect(context.schedulePlannedTasks).not.toHaveBeenCalled();
|
||||
expect(context.plannedTaskService!.approvePlan).not.toHaveBeenCalled();
|
||||
// UI checklist still resets so the rejected todos don't linger on screen
|
||||
expect(context.taskStorage.save).toHaveBeenCalledWith('test-thread', { tasks: [] });
|
||||
expect(context.eventBus.publish).toHaveBeenCalledWith(
|
||||
'test-thread',
|
||||
expect.objectContaining({
|
||||
type: 'tasks-update',
|
||||
payload: { tasks: { tasks: [] }, planItems: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows a same-turn revision after rejection (awaiting_approval with same runId)', async () => {
|
||||
// After rejection, the graph stays in awaiting_approval with planRunId ===
|
||||
// context.runId. The next create-tasks call must pass threadHasExistingPlan
|
||||
// so the revision flow advertised by the tool result works.
|
||||
const context = createMockContext({
|
||||
currentUserMessage: 'revise the plan',
|
||||
runId: 'run-1',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
getGraph: jest.fn().mockResolvedValue({
|
||||
threadId: 'test-thread',
|
||||
status: 'awaiting_approval',
|
||||
planRunId: 'run-1',
|
||||
tasks: [],
|
||||
} as unknown as Awaited<ReturnType<PlannedTaskService['getGraph']>>),
|
||||
}),
|
||||
});
|
||||
const tool = createPlanTool(context) as unknown as Executable;
|
||||
const suspend = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const out = await tool.execute({ tasks: validTasks() }, { agent: { suspend } });
|
||||
|
||||
expect(out.result).toBe('Awaiting approval');
|
||||
expect(context.plannedTaskService!.createPlan).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a fresh request when an orphan awaiting_approval graph exists from a previous run', async () => {
|
||||
// LLM rejected a prior plan and never revised; the graph orphans in
|
||||
// awaiting_approval with a stale planRunId. A new turn must still go
|
||||
// through planner discovery, not silently bypass the guard.
|
||||
const context = createMockContext({
|
||||
currentUserMessage: 'unrelated new request',
|
||||
runId: 'run-2',
|
||||
plannedTaskService: makePlannedTaskService({
|
||||
getGraph: jest.fn().mockResolvedValue({
|
||||
threadId: 'test-thread',
|
||||
status: 'awaiting_approval',
|
||||
planRunId: 'run-1',
|
||||
tasks: [],
|
||||
} as unknown as Awaited<ReturnType<PlannedTaskService['getGraph']>>),
|
||||
}),
|
||||
});
|
||||
const tool = createPlanTool(context) as unknown as Executable;
|
||||
|
||||
const out = await tool.execute({ tasks: validTasks() }, { agent: { suspend: jest.fn() } });
|
||||
|
||||
expect(out.result).toMatch(/^Error: `create-tasks` is for replanning only/);
|
||||
expect(context.plannedTaskService!.createPlan).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+46
-1
@@ -52,7 +52,11 @@ function createMockContext(overrides?: Partial<OrchestrationContext>): Orchestra
|
||||
domainTools,
|
||||
abortSignal: new AbortController().signal,
|
||||
taskStorage: {} as TaskStorage,
|
||||
spawnBackgroundTask: jest.fn(),
|
||||
spawnBackgroundTask: jest.fn(() => ({
|
||||
status: 'started' as const,
|
||||
taskId: 'spawn-task-id',
|
||||
agentId: 'spawn-agent-id',
|
||||
})),
|
||||
cancelBackgroundTask: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
@@ -144,5 +148,46 @@ describe('research-with-agent tool', () => {
|
||||
|
||||
expect(result.result).toBe('Error: background task support not available.');
|
||||
});
|
||||
|
||||
it('does not publish agent-spawned when spawn returns duplicate', async () => {
|
||||
// The single-flight dedupe path used to emit a phantom `agent-spawned`
|
||||
// before checking the spawn outcome — leaving an orphan card in the UI.
|
||||
const context = createMockContext({
|
||||
spawnBackgroundTask: jest.fn(() => ({
|
||||
status: 'duplicate' as const,
|
||||
existing: {
|
||||
taskId: 'task-existing',
|
||||
agentId: 'agent-existing',
|
||||
role: 'web-researcher',
|
||||
},
|
||||
})),
|
||||
});
|
||||
const tool = createResearchWithAgentTool(context);
|
||||
|
||||
const result = (await tool.execute!({ goal: 'test' }, {} as never)) as {
|
||||
result: string;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
expect(result.result).toContain('Research already in progress');
|
||||
expect(result.taskId).toBe('task-existing');
|
||||
expect(context.eventBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not publish agent-spawned when spawn returns limit-reached', async () => {
|
||||
const context = createMockContext({
|
||||
spawnBackgroundTask: jest.fn(() => ({ status: 'limit-reached' as const })),
|
||||
});
|
||||
const tool = createResearchWithAgentTool(context);
|
||||
|
||||
const result = (await tool.execute!({ goal: 'test' }, {} as never)) as {
|
||||
result: string;
|
||||
taskId: string;
|
||||
};
|
||||
|
||||
expect(result.result).toContain('limit reached');
|
||||
expect(result.taskId).toBe('');
|
||||
expect(context.eventBus.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+509
@@ -0,0 +1,509 @@
|
||||
import type {
|
||||
InstanceAiDataTableService,
|
||||
InstanceAiWorkflowService,
|
||||
OrchestrationContext,
|
||||
WorkflowTaskService,
|
||||
} from '../../../types';
|
||||
import type { WorkflowBuildOutcome } from '../../../workflow-loop/workflow-loop-state';
|
||||
import { createVerifyBuiltWorkflowTool } from '../verify-built-workflow.tool';
|
||||
|
||||
type ExecutionRunResult = {
|
||||
executionId?: string | null;
|
||||
status: 'success' | 'error' | 'waiting' | 'running' | 'unknown';
|
||||
data?: Record<string, unknown>;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
interface VerifyToolContext {
|
||||
workflowTaskService: WorkflowTaskService;
|
||||
domainContext: {
|
||||
executionService: {
|
||||
run: jest.Mock<
|
||||
Promise<ExecutionRunResult>,
|
||||
[string, Record<string, unknown> | undefined, { timeout?: number; pinData?: unknown }]
|
||||
>;
|
||||
};
|
||||
workflowService?: InstanceAiWorkflowService;
|
||||
dataTableService?: InstanceAiDataTableService;
|
||||
};
|
||||
logger: { debug: jest.Mock; info: jest.Mock; warn: jest.Mock; error: jest.Mock };
|
||||
}
|
||||
|
||||
function makeBuildOutcome(overrides: Partial<WorkflowBuildOutcome> = {}): WorkflowBuildOutcome {
|
||||
return {
|
||||
workItemId: 'wi-1',
|
||||
taskId: 'task-1',
|
||||
workflowId: 'wf-1',
|
||||
submitted: true,
|
||||
triggerType: 'manual_or_testable',
|
||||
needsUserInput: false,
|
||||
summary: 'built ok',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContext(
|
||||
outcome: WorkflowBuildOutcome | undefined,
|
||||
runResult: ExecutionRunResult,
|
||||
overrides: {
|
||||
workflowNodes?: Array<{ name?: string; type: string; parameters?: Record<string, unknown> }>;
|
||||
tableRows?: Record<string, Array<Record<string, unknown>>>;
|
||||
queriesAfterRun?: Record<string, Array<Record<string, unknown>>>;
|
||||
/** Throw `snapshotError` on the first queryRows call for the given table id. */
|
||||
snapshotErrors?: Record<string, Error>;
|
||||
} = {},
|
||||
) {
|
||||
const updateBuildOutcome = jest.fn(
|
||||
async (_workItemId: string, _update: Partial<WorkflowBuildOutcome>) => {
|
||||
await Promise.resolve();
|
||||
},
|
||||
);
|
||||
const run = jest.fn(
|
||||
async (
|
||||
_workflowId: string,
|
||||
_inputData: Record<string, unknown> | undefined,
|
||||
_options: { timeout?: number; pinData?: unknown },
|
||||
): Promise<ExecutionRunResult> => {
|
||||
await Promise.resolve();
|
||||
return runResult;
|
||||
},
|
||||
);
|
||||
|
||||
type QueryRowsResult = { count: number; data: Array<Record<string, unknown>> };
|
||||
/**
|
||||
* Track which dataTableIds we've already "seen a last page for" — any call
|
||||
* after the snapshot phase for a given table switches to `queriesAfterRun`.
|
||||
*/
|
||||
const snapshotDone = new Set<string>();
|
||||
const queryRows = jest.fn(
|
||||
async (
|
||||
dataTableId: string,
|
||||
opts?: { limit?: number; offset?: number },
|
||||
): Promise<QueryRowsResult> => {
|
||||
const snapshotError = overrides.snapshotErrors?.[dataTableId];
|
||||
if (snapshotError && !snapshotDone.has(dataTableId)) {
|
||||
// Mark done so post-run phase doesn't keep throwing if that matters.
|
||||
snapshotDone.add(dataTableId);
|
||||
throw snapshotError;
|
||||
}
|
||||
const limit = opts?.limit ?? Number.MAX_SAFE_INTEGER;
|
||||
const offset = opts?.offset ?? 0;
|
||||
const baseRows: Array<Record<string, unknown>> = snapshotDone.has(dataTableId)
|
||||
? (overrides.queriesAfterRun?.[dataTableId] ?? overrides.tableRows?.[dataTableId] ?? [])
|
||||
: (overrides.tableRows?.[dataTableId] ?? []);
|
||||
const page = baseRows.slice(offset, offset + limit);
|
||||
// If this page is the last one of the snapshot (fewer than `limit` rows),
|
||||
// any subsequent calls for this table should fall through to post-run data.
|
||||
if (!snapshotDone.has(dataTableId) && page.length < limit) {
|
||||
snapshotDone.add(dataTableId);
|
||||
}
|
||||
await Promise.resolve();
|
||||
return { count: baseRows.length, data: page };
|
||||
},
|
||||
);
|
||||
type DeleteRowsFilter = {
|
||||
type: 'and' | 'or';
|
||||
filters: Array<{
|
||||
columnName: string;
|
||||
condition: 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like';
|
||||
value: string | number | boolean | null;
|
||||
}>;
|
||||
};
|
||||
const deleteRows = jest.fn(async (_dataTableId: string, _filter: DeleteRowsFilter) => {
|
||||
await Promise.resolve();
|
||||
return { deletedCount: 0, dataTableId: '', tableName: '', projectId: '' };
|
||||
});
|
||||
|
||||
const workflowService = {
|
||||
getAsWorkflowJSON: jest.fn(async () => {
|
||||
await Promise.resolve();
|
||||
return { nodes: overrides.workflowNodes ?? [] };
|
||||
}),
|
||||
} as unknown as InstanceAiWorkflowService;
|
||||
|
||||
const dataTableService = {
|
||||
queryRows,
|
||||
deleteRows,
|
||||
} as unknown as InstanceAiDataTableService;
|
||||
|
||||
const logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
const ctx: VerifyToolContext = {
|
||||
workflowTaskService: {
|
||||
reportBuildOutcome: jest.fn(),
|
||||
reportVerificationVerdict: jest.fn(),
|
||||
getBuildOutcome: jest.fn(async () => {
|
||||
await Promise.resolve();
|
||||
return outcome;
|
||||
}),
|
||||
updateBuildOutcome,
|
||||
} as unknown as WorkflowTaskService,
|
||||
domainContext: {
|
||||
executionService: { run },
|
||||
workflowService,
|
||||
dataTableService,
|
||||
},
|
||||
logger,
|
||||
};
|
||||
return { ctx, updateBuildOutcome, queryRows, deleteRows };
|
||||
}
|
||||
|
||||
async function runTool(
|
||||
ctx: VerifyToolContext,
|
||||
input: { workItemId: string; workflowId: string; inputData?: Record<string, unknown> },
|
||||
) {
|
||||
const tool = createVerifyBuiltWorkflowTool(ctx as unknown as OrchestrationContext);
|
||||
// createTool's execute signature wraps the user function; invoke directly via internal handler
|
||||
const handler = (
|
||||
tool as unknown as {
|
||||
execute: (input: {
|
||||
workItemId: string;
|
||||
workflowId: string;
|
||||
inputData?: Record<string, unknown>;
|
||||
}) => Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
executionId?: string;
|
||||
status?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}>;
|
||||
}
|
||||
).execute;
|
||||
return await handler(input);
|
||||
}
|
||||
|
||||
describe('verify-built-workflow tool', () => {
|
||||
it('persists a success verification record onto the build outcome', async () => {
|
||||
const { ctx, updateBuildOutcome } = makeContext(makeBuildOutcome(), {
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
data: { 'Form Trigger': {}, 'Insert Row': {} },
|
||||
});
|
||||
|
||||
const result = await runTool(ctx, {
|
||||
workItemId: 'wi-1',
|
||||
workflowId: 'wf-1',
|
||||
inputData: { name: 'Alice' },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(updateBuildOutcome).toHaveBeenCalledTimes(1);
|
||||
const call = updateBuildOutcome.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const update = call[1];
|
||||
expect(update.verification).toMatchObject({
|
||||
attempted: true,
|
||||
success: true,
|
||||
executionId: 'exec-1',
|
||||
status: 'success',
|
||||
});
|
||||
expect(update.verification?.evidence?.nodesExecuted).toEqual(['Form Trigger', 'Insert Row']);
|
||||
expect(typeof update.verification?.verifiedAt).toBe('string');
|
||||
});
|
||||
|
||||
it('persists a failure verification record with failureSignature', async () => {
|
||||
const { ctx, updateBuildOutcome } = makeContext(makeBuildOutcome(), {
|
||||
executionId: 'exec-2',
|
||||
status: 'error',
|
||||
error: 'Node "Insert Row" crashed',
|
||||
});
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-1', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(updateBuildOutcome).toHaveBeenCalledTimes(1);
|
||||
const call = updateBuildOutcome.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
const update = call[1];
|
||||
expect(update.verification).toMatchObject({
|
||||
attempted: true,
|
||||
success: false,
|
||||
status: 'error',
|
||||
failureSignature: 'Node "Insert Row" crashed',
|
||||
evidence: { errorMessage: 'Node "Insert Row" crashed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns an error result when no build outcome exists', async () => {
|
||||
const { ctx, updateBuildOutcome } = makeContext(undefined, {
|
||||
status: 'success',
|
||||
});
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-missing', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toMatch(/No build outcome found/);
|
||||
expect(updateBuildOutcome).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('swallows storage errors when persisting verification', async () => {
|
||||
const { ctx, updateBuildOutcome } = makeContext(makeBuildOutcome(), {
|
||||
executionId: 'exec-3',
|
||||
status: 'success',
|
||||
});
|
||||
updateBuildOutcome.mockRejectedValueOnce(new Error('storage unavailable'));
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-1', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.executionId).toBe('exec-3');
|
||||
});
|
||||
|
||||
it('cleans up rows inserted by the verification run, reading row IDs from the node output', async () => {
|
||||
const { ctx, deleteRows } = makeContext(
|
||||
makeBuildOutcome(),
|
||||
{
|
||||
executionId: 'exec-4',
|
||||
status: 'success',
|
||||
// The insert node's output is what drives the delete set — a concurrent
|
||||
// writer's row would never appear here, so it's safe from cleanup.
|
||||
data: {
|
||||
'Lead Form': [{ name: 'Test' }],
|
||||
'Insert Lead': [{ id: 3, name: 'Test', email: 'test@example.com' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
workflowNodes: [
|
||||
{
|
||||
name: 'Insert Lead',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
parameters: { operation: 'insert', dataTableId: 'tbl-leads' },
|
||||
},
|
||||
],
|
||||
tableRows: { 'tbl-leads': [{ id: 1 }, { id: 2 }] },
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runTool(ctx, {
|
||||
workItemId: 'wi-1',
|
||||
workflowId: 'wf-1',
|
||||
inputData: { name: 'Test' },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deleteRows).toHaveBeenCalledTimes(1);
|
||||
const call = deleteRows.mock.calls[0];
|
||||
expect(call).toBeDefined();
|
||||
expect(call[0]).toBe('tbl-leads');
|
||||
expect(call[1]).toEqual({
|
||||
type: 'or',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('never deletes rows produced by an upsert node, even when the ID looks new', async () => {
|
||||
// Upsert outputs cannot distinguish a freshly-created row from a match on
|
||||
// an existing row. A concurrent writer inserting between snapshot and
|
||||
// upsert would yield an ID that looks "new" to ID-diff but actually
|
||||
// belongs to that writer — deleting it would destroy production data.
|
||||
// We therefore skip cleanup for upsert nodes entirely.
|
||||
const { ctx, deleteRows } = makeContext(
|
||||
makeBuildOutcome(),
|
||||
{
|
||||
executionId: 'exec-upsert',
|
||||
status: 'success',
|
||||
data: {
|
||||
// id=99 is not in the pre-verify snapshot — under the old
|
||||
// ID-diff logic this would be deleted. The new contract leaves
|
||||
// it alone because we cannot prove it was created by verify.
|
||||
'Upsert Lead': [{ id: 99, name: 'Could be a concurrent writer' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
workflowNodes: [
|
||||
{
|
||||
name: 'Upsert Lead',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
parameters: { operation: 'upsert', dataTableId: 'tbl-leads' },
|
||||
},
|
||||
],
|
||||
tableRows: { 'tbl-leads': [{ id: 1 }, { id: 2 }] },
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-1', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deleteRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not delete rows from concurrent writers that never appeared in the node output', async () => {
|
||||
const { ctx, deleteRows } = makeContext(
|
||||
makeBuildOutcome(),
|
||||
{
|
||||
executionId: 'exec-concurrent',
|
||||
status: 'success',
|
||||
// The verify's insert node only emitted id=3; a concurrent writer that
|
||||
// added id=4 after the snapshot would be invisible to us and must NOT
|
||||
// be deleted. This test asserts the delete set is driven purely by
|
||||
// node output, not by a post-verify table-wide diff.
|
||||
data: {
|
||||
'Insert Lead': [{ id: 3, name: 'VerifyRow' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
workflowNodes: [
|
||||
{
|
||||
name: 'Insert Lead',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
parameters: { operation: 'insert', dataTableId: 'tbl-leads' },
|
||||
},
|
||||
],
|
||||
tableRows: { 'tbl-leads': [{ id: 1 }, { id: 2 }] },
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-1', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deleteRows).toHaveBeenCalledTimes(1);
|
||||
// Only id=3 (the row our insert node emitted), not id=4 from the concurrent writer.
|
||||
expect(deleteRows.mock.calls[0][1]).toEqual({
|
||||
type: 'or',
|
||||
filters: [{ columnName: 'id', condition: 'eq', value: 3 }],
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a waiting status with output as a successful run (e.g. Form Trigger response page)', async () => {
|
||||
const { ctx, updateBuildOutcome } = makeContext(makeBuildOutcome(), {
|
||||
executionId: 'exec-form-1',
|
||||
status: 'waiting',
|
||||
data: {
|
||||
'Lead Form': {},
|
||||
'Insert Lead': {},
|
||||
'Confirmation Page': {},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runTool(ctx, {
|
||||
workItemId: 'wi-1',
|
||||
workflowId: 'wf-1',
|
||||
inputData: { name: 'Alice', email: 'a@b.c' },
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.status).toBe('waiting');
|
||||
expect(updateBuildOutcome).toHaveBeenCalledTimes(1);
|
||||
const update = updateBuildOutcome.mock.calls[0][1];
|
||||
expect(update.verification).toMatchObject({
|
||||
attempted: true,
|
||||
success: true,
|
||||
status: 'waiting',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a waiting status with no output as failure', async () => {
|
||||
const { ctx } = makeContext(makeBuildOutcome(), {
|
||||
executionId: 'exec-form-2',
|
||||
status: 'waiting',
|
||||
data: {},
|
||||
});
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-1', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('paginates the pre-verify snapshot so a pathological insert output cannot delete a pre-existing row past the first page', async () => {
|
||||
// Build a table with 1500 rows — past the snapshot page size.
|
||||
// The insert node's output is `id=1234` (a row that already existed). If
|
||||
// pagination is broken the snapshot only contains ids 1..1000, and the
|
||||
// snapshot's defensive filter wouldn't protect id=1234 — the cleanup
|
||||
// would delete a pre-existing row.
|
||||
const bigTable: Array<Record<string, unknown>> = Array.from({ length: 1500 }, (_v, i) => ({
|
||||
id: i + 1,
|
||||
}));
|
||||
const { ctx, deleteRows, queryRows } = makeContext(
|
||||
makeBuildOutcome(),
|
||||
{
|
||||
executionId: 'exec-insert-past-page',
|
||||
status: 'success',
|
||||
data: {
|
||||
// Insert node's output references id=1234 — beyond the single-page cap.
|
||||
'Insert Lead': [{ id: 1234, name: 'Existing', stage: 'qualified' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
workflowNodes: [
|
||||
{
|
||||
name: 'Insert Lead',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
parameters: { operation: 'insert', dataTableId: 'tbl-leads' },
|
||||
},
|
||||
],
|
||||
tableRows: { 'tbl-leads': bigTable },
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-1', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Must have made more than one snapshot query to cover a 1500-row table.
|
||||
const snapshotCalls = queryRows.mock.calls.filter(
|
||||
(c) => c[0] === 'tbl-leads' && typeof (c[1] as { offset?: number })?.offset === 'number',
|
||||
);
|
||||
expect(snapshotCalls.length).toBeGreaterThan(1);
|
||||
// And the pre-existing row must not have been deleted.
|
||||
expect(deleteRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips insert cleanup for a table when the pre-verify snapshot read fails', async () => {
|
||||
const { ctx, deleteRows } = makeContext(
|
||||
makeBuildOutcome(),
|
||||
{
|
||||
executionId: 'exec-snapshot-fail',
|
||||
status: 'success',
|
||||
data: {
|
||||
'Insert Lead': [{ id: 42, name: 'Existing', stage: 'qualified' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
workflowNodes: [
|
||||
{
|
||||
name: 'Insert Lead',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
parameters: { operation: 'insert', dataTableId: 'tbl-leads' },
|
||||
},
|
||||
],
|
||||
tableRows: { 'tbl-leads': [{ id: 42 }] },
|
||||
snapshotErrors: { 'tbl-leads': new Error('DB unavailable') },
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-1', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deleteRows).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not delete rows for dataTable nodes that only read', async () => {
|
||||
const { ctx, deleteRows } = makeContext(
|
||||
makeBuildOutcome(),
|
||||
{
|
||||
executionId: 'exec-5',
|
||||
status: 'success',
|
||||
data: { 'Lookup Lead': [{ id: 1, name: 'Existing' }] },
|
||||
},
|
||||
{
|
||||
workflowNodes: [
|
||||
{
|
||||
name: 'Lookup Lead',
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
parameters: { operation: 'get', dataTableId: 'tbl-leads' },
|
||||
},
|
||||
],
|
||||
tableRows: { 'tbl-leads': [{ id: 1 }] },
|
||||
},
|
||||
);
|
||||
|
||||
const result = await runTool(ctx, { workItemId: 'wi-1', workflowId: 'wf-1' });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deleteRows).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import { z } from 'zod';
|
||||
|
||||
import type { BlueprintAccumulator } from './blueprint-accumulator';
|
||||
import {
|
||||
blueprintCheckpointItemSchema,
|
||||
blueprintDataTableItemSchema,
|
||||
blueprintDelegateItemSchema,
|
||||
blueprintResearchItemSchema,
|
||||
@@ -52,6 +53,7 @@ const addPlanItemInputSchema = z.object({
|
||||
blueprintDataTableItemSchema.extend({ kind: z.literal('data-table') }),
|
||||
blueprintResearchItemSchema.extend({ kind: z.literal('research') }),
|
||||
blueprintDelegateItemSchema.extend({ kind: z.literal('delegate') }),
|
||||
blueprintCheckpointItemSchema.extend({ kind: z.literal('checkpoint') }),
|
||||
]),
|
||||
});
|
||||
|
||||
@@ -62,9 +64,10 @@ export function createAddPlanItemTool(
|
||||
return createTool({
|
||||
id: 'add-plan-item',
|
||||
description:
|
||||
'Add a single plan item (data table, workflow, research, or delegate task). ' +
|
||||
'Add a single plan item (data table, workflow, research, delegate, or checkpoint task). ' +
|
||||
'Call once per item as you design it — each call makes the item visible to the user immediately. ' +
|
||||
'Emit data tables FIRST. Add workflow items only if the request requires automation. ' +
|
||||
'Add a checkpoint item AFTER its target workflow(s) so the orchestrator can verify the result end-to-end. ' +
|
||||
'Set summary and assumptions on your first call.',
|
||||
inputSchema: addPlanItemInputSchema,
|
||||
outputSchema: z.object({ result: z.string() }),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import type {
|
||||
BlueprintCheckpointItem,
|
||||
BlueprintDataTableItem,
|
||||
BlueprintDelegateItem,
|
||||
BlueprintResearchItem,
|
||||
@@ -37,7 +38,8 @@ type BlueprintItem =
|
||||
| (BlueprintWorkflowItem & { kind: 'workflow' })
|
||||
| (BlueprintDataTableItem & { kind: 'data-table' })
|
||||
| (BlueprintResearchItem & { kind: 'research' })
|
||||
| (BlueprintDelegateItem & { kind: 'delegate' });
|
||||
| (BlueprintDelegateItem & { kind: 'delegate' })
|
||||
| (BlueprintCheckpointItem & { kind: 'checkpoint' });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-item conversion helpers
|
||||
@@ -147,6 +149,16 @@ function delegateItemToTask(di: BlueprintDelegateItem): PlannedTaskInput {
|
||||
};
|
||||
}
|
||||
|
||||
function checkpointItemToTask(c: BlueprintCheckpointItem): PlannedTaskInput {
|
||||
return {
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
kind: 'checkpoint',
|
||||
spec: c.instructions,
|
||||
deps: c.dependsOn,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BlueprintAccumulator
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -160,6 +172,8 @@ export class BlueprintAccumulator {
|
||||
|
||||
private delegateItems: BlueprintDelegateItem[] = [];
|
||||
|
||||
private checkpoints: BlueprintCheckpointItem[] = [];
|
||||
|
||||
private tasks: PlannedTaskInput[] = [];
|
||||
|
||||
private summary = '';
|
||||
@@ -197,6 +211,12 @@ export class BlueprintAccumulator {
|
||||
task = delegateItemToTask(di);
|
||||
break;
|
||||
}
|
||||
case 'checkpoint': {
|
||||
const { kind: _, ...c } = item;
|
||||
this.upsertArray(this.checkpoints, c);
|
||||
task = checkpointItemToTask(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.upsertTask(task);
|
||||
@@ -235,7 +255,10 @@ export class BlueprintAccumulator {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove an item by ID. Returns true if found and removed. */
|
||||
/** Remove an item by ID. Returns true if found and removed.
|
||||
* Cascade-removes any checkpoint that loses its last build-workflow dep as
|
||||
* a result — otherwise submit-plan would later fail validation because
|
||||
* checkpoints must depend on at least one build-workflow task. */
|
||||
removeItem(id: string): boolean {
|
||||
const taskIdx = this.tasks.findIndex((t) => t.id === id);
|
||||
if (taskIdx < 0) return false;
|
||||
@@ -245,10 +268,26 @@ export class BlueprintAccumulator {
|
||||
this.removeFromArray(this.workflows, id);
|
||||
this.removeFromArray(this.researchItems, id);
|
||||
this.removeFromArray(this.delegateItems, id);
|
||||
this.removeFromArray(this.checkpoints, id);
|
||||
// Clean up dangling dep references in remaining tasks
|
||||
for (const task of this.tasks) {
|
||||
task.deps = task.deps.filter((dep) => dep !== id);
|
||||
}
|
||||
|
||||
// Cascade-remove orphaned checkpoints: any checkpoint whose deps no
|
||||
// longer reference a build-workflow task is invalid and must go too.
|
||||
const workflowIds = new Set(this.workflows.map((w) => w.id));
|
||||
const orphanedCheckpointIds: string[] = [];
|
||||
for (const cp of this.checkpoints) {
|
||||
const stillHasWorkflowDep = cp.dependsOn.some((depId) => workflowIds.has(depId));
|
||||
if (!stillHasWorkflowDep) {
|
||||
orphanedCheckpointIds.push(cp.id);
|
||||
}
|
||||
}
|
||||
for (const orphanId of orphanedCheckpointIds) {
|
||||
this.removeItem(orphanId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,24 @@ export const blueprintDelegateItemSchema = z.object({
|
||||
dependsOn: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const blueprintCheckpointItemSchema = z.object({
|
||||
id: z.string().describe('Stable ID — preserved as task ID'),
|
||||
title: z
|
||||
.string()
|
||||
.describe(
|
||||
'User-readable verification goal (e.g., "Verify Daily Email workflow runs without errors")',
|
||||
),
|
||||
instructions: z
|
||||
.string()
|
||||
.describe(
|
||||
'Detailed verification steps the orchestrator must execute — which tools to call, the expected pass condition. The orchestrator runs this itself (no sub-agent).',
|
||||
),
|
||||
dependsOn: z
|
||||
.array(z.string())
|
||||
.min(1)
|
||||
.describe('IDs of items this checkpoint verifies. Must include at least one workflow item ID.'),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Top-level blueprint schema
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -64,6 +82,7 @@ export const planningBlueprintSchema = z.object({
|
||||
dataTables: z.array(blueprintDataTableItemSchema).default([]),
|
||||
researchItems: z.array(blueprintResearchItemSchema).default([]),
|
||||
delegateItems: z.array(blueprintDelegateItemSchema).default([]),
|
||||
checkpointItems: z.array(blueprintCheckpointItemSchema).default([]),
|
||||
assumptions: z.array(z.string()).default([]).describe('Assumptions the plan relies on'),
|
||||
openQuestions: z
|
||||
.array(z.string())
|
||||
@@ -76,3 +95,4 @@ export type BlueprintWorkflowItem = z.infer<typeof blueprintWorkflowItemSchema>;
|
||||
export type BlueprintDataTableItem = z.infer<typeof blueprintDataTableItemSchema>;
|
||||
export type BlueprintResearchItem = z.infer<typeof blueprintResearchItemSchema>;
|
||||
export type BlueprintDelegateItem = z.infer<typeof blueprintDelegateItemSchema>;
|
||||
export type BlueprintCheckpointItem = z.infer<typeof blueprintCheckpointItemSchema>;
|
||||
|
||||
@@ -274,17 +274,24 @@ const SANDBOX_WORKFLOW_RULES = `Follow these rules strictly when generating work
|
||||
- Example: \`credentials: { slackApi: { id: 'yXYBqho73obh58ZS', name: 'Slack Bot' } }\`
|
||||
- The key (e.g. \`slackApi\`) is the credential **type** from the node type definition
|
||||
|
||||
2. **Handle empty outputs with \`alwaysOutputData: true\`**
|
||||
- Nodes that query data (Data Table get, Google Sheets lookup, HTTP Request, etc.) may return 0 items
|
||||
- When a node returns 0 items, all downstream nodes are SKIPPED — the workflow chain breaks silently
|
||||
- Set \`alwaysOutputData: true\` on any node whose output feeds downstream nodes and might return empty results
|
||||
- Common cases: fresh/empty Data Tables, filtered queries, conditional lookups, API searches with no matches
|
||||
- Example: \`config: { ..., alwaysOutputData: true }\`
|
||||
2. **Trust empty item lists — don't synthesize fake items**
|
||||
- When a query returns 0 items, downstream nodes simply don't run for that execution. For scheduled or polling triggers this is the correct "nothing to do this round" signal — the next run will execute normally when data appears.
|
||||
- DO NOT add \`alwaysOutputData: true\` just to "keep the chain alive." Forcing an empty \`{}\` item downstream is what causes \`undefined\` reads, failed HTTP calls to \`GET undefined\`, and Code-node crashes on missing fields.
|
||||
- DO NOT add an IF gate before a loop to check "has items?" — loops (\`splitInBatches\`, per-item nodes, \`filter\`) already no-op on empty input. The gate is redundant and adds a failure surface.
|
||||
- \`alwaysOutputData: true\` is only correct when you specifically need a downstream branch to run on the "empty" case — e.g. a dedicated "no matches found" notification path. In that case, pair it with an \`IF\` that explicitly checks for the empty case and routes accordingly. Never use it as a default.
|
||||
- To drop invalid items mid-pipeline, use a \`filter\` node. A \`filter\` that rejects everything emits 0 items and the chain correctly stops — no \`IF\` + \`splitInBatches\` composition needed.
|
||||
|
||||
3. **Use \`executeOnce: true\` for single-execution nodes**
|
||||
- When a node receives N items but should only execute once (not N times), set \`executeOnce: true\`
|
||||
- Common cases: sending a summary notification, generating a report, calling an API that doesn't need per-item execution
|
||||
- Example: \`config: { ..., executeOnce: true }\``;
|
||||
- Example: \`config: { ..., executeOnce: true }\`
|
||||
|
||||
4. **Pick the right control-flow primitive**
|
||||
- **Per-item loop with side effects (fetch, embed, write)** → \`splitInBatches\` with \`batchSize: 1\` feeding the per-item work, loop back via \`nextBatch\`. No \`IF\` gate before it.
|
||||
- **Drop items that don't match a predicate** → \`filter\`. It emits 0 items when nothing matches, and the chain stops cleanly.
|
||||
- **Two mutually exclusive paths that both do real work** → \`IF\` (\`onTrue\` / \`onFalse\`).
|
||||
- **Many mutually exclusive paths keyed off a value** → \`switch\` (\`onCase\`).
|
||||
- Nested control flow is supported: \`ifNode.onTrue(loopBuilder)\`, \`switchNode.onCase(0, loopBuilder)\`, and \`splitInBatches(sib).onEachBatch(ifElseBuilder)\` all compile and wire correctly. Use them when the semantics genuinely call for it, not as a workaround for empty-list handling.`;
|
||||
|
||||
function composeSdkRulesAndPatterns(mode: 'tool' | 'sandbox'): string {
|
||||
// Shared WORKFLOW_SDK_PATTERNS uses `newCredential('X')` throughout. That
|
||||
|
||||
@@ -29,6 +29,7 @@ import { createVerifyBuiltWorkflowTool } from './verify-built-workflow.tool';
|
||||
import { registerWithMastra } from '../../agent/register-with-mastra';
|
||||
import { buildSubAgentBriefing } from '../../agent/sub-agent-briefing';
|
||||
import { MAX_STEPS } from '../../constants/max-steps';
|
||||
import { TEMPERATURE } from '../../constants/model-settings';
|
||||
import type { Logger } from '../../logger';
|
||||
import { createLlmStepTraceHooks } from '../../runtime/resumable-stream-executor';
|
||||
import { consumeStreamWithHitl } from '../../stream/consume-with-hitl';
|
||||
@@ -48,20 +49,6 @@ import { buildCredentialMap, type CredentialMap } from '../workflows/resolve-cre
|
||||
import { createIdentityEnforcedSubmitWorkflowTool } from '../workflows/submit-workflow-identity';
|
||||
import { type SubmitWorkflowAttempt } from '../workflows/submit-workflow.tool';
|
||||
|
||||
/** Trigger types that cannot be test-fired programmatically (need an external request). */
|
||||
const UNTESTABLE_TRIGGERS = new Set([
|
||||
'n8n-nodes-base.webhook',
|
||||
'n8n-nodes-base.formTrigger',
|
||||
'@n8n/n8n-nodes-langchain.mcpTrigger',
|
||||
'@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
]);
|
||||
|
||||
/** Human-readable label derived from a node type string, e.g. "n8n-nodes-base.formTrigger" → "form" */
|
||||
function triggerLabel(nodeType: string): string {
|
||||
const short = nodeType.split('.').pop() ?? nodeType;
|
||||
return short.replace(/Trigger$/i, '').toLowerCase() || short.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the AI-builder temporary marker from the build's main workflow so the
|
||||
* run-finish reap leaves it alone. Best-effort: a failure here means the
|
||||
@@ -113,14 +100,13 @@ export function recordSuccessfulWorkflowBuilds(
|
||||
};
|
||||
}
|
||||
|
||||
const UNTESTABLE_TRIGGER_LABELS = [...UNTESTABLE_TRIGGERS].map(triggerLabel).join(', ');
|
||||
|
||||
function detectTriggerType(attempt: SubmitWorkflowAttempt | undefined): TriggerType {
|
||||
if (!attempt?.triggerNodeTypes || attempt.triggerNodeTypes.length === 0) {
|
||||
return 'manual_or_testable';
|
||||
}
|
||||
const allUntestable = attempt.triggerNodeTypes.every((t) => UNTESTABLE_TRIGGERS.has(t));
|
||||
return allUntestable ? 'trigger_only' : 'manual_or_testable';
|
||||
function detectTriggerType(_attempt: SubmitWorkflowAttempt | undefined): TriggerType {
|
||||
// Every trigger type the builder can produce is testable — manual/schedule via
|
||||
// `executions(action="run")`, event-based via `verify-built-workflow` with inputData.
|
||||
// `trigger_only` is reserved for workflows the builder could not fully wire
|
||||
// (e.g. unresolved placeholders), which is detected separately via
|
||||
// `hasUnresolvedPlaceholders` in buildOutcome().
|
||||
return 'manual_or_testable';
|
||||
}
|
||||
|
||||
function buildOutcome(
|
||||
@@ -164,9 +150,13 @@ You are running as a detached background task. Do not stop after a successful su
|
||||
|
||||
Your job is done when ONE of these is true:
|
||||
- the workflow is verified (ran successfully)
|
||||
- the workflow uses only event triggers (${UNTESTABLE_TRIGGER_LABELS}) and cannot be runtime-tested — stop after a successful submit. Do NOT publish it; the orchestrator will handle setup and publishing.
|
||||
- you are blocked after one repair attempt per unique failure
|
||||
|
||||
Do NOT stop after a successful submit without verifying. Every trigger type is testable:
|
||||
manual / schedule via \`executions(action="run")\`; event-based triggers (form, webhook,
|
||||
chat, mcp, linear, github, slack, etc.) via \`verify-built-workflow\` with an \`inputData\`
|
||||
payload. The pin-data adapter injects it as the trigger node's output.
|
||||
|
||||
### Submit discipline
|
||||
|
||||
**Every file edit MUST be followed by submit-workflow before you do anything else.**
|
||||
@@ -174,8 +164,13 @@ The system tracks file hashes. If you edit the code and then call \`executions(a
|
||||
|
||||
### Verification
|
||||
|
||||
- If submit-workflow returned mocked credentials, call verify-built-workflow with the workItemId
|
||||
- Otherwise call \`executions(action="run")\` to test (skip for trigger-only workflows). For event-based triggers (Linear, GitHub, Slack, etc.), pass \`inputData\` with sample data matching the trigger's expected output shape — the system injects it as the trigger node's output.
|
||||
- If submit-workflow returned mocked credentials, call \`verify-built-workflow\` with the workItemId.
|
||||
- Otherwise pick based on trigger type:
|
||||
- **Manual / Schedule** — \`executions(action="run")\`.
|
||||
- **Form Trigger** — \`verify-built-workflow\` with \`inputData\` as a flat field map, e.g. \`{name: "Alice", email: "a@b.c"}\`. Do NOT wrap in \`formFields\` — production Form Trigger emits fields directly on \`$json\`, and the adapter rejects wrapped payloads.
|
||||
- **Webhook** — \`verify-built-workflow\` with \`inputData\` as the body payload, e.g. \`{event: "signup", userId: "..."}\`. Adapter wraps it under \`body\`; downstream expressions use \`$json.body.<field>\`.
|
||||
- **Chat Trigger** — \`verify-built-workflow\` with \`{chatInput: "user message"}\`.
|
||||
- **Other event triggers (Linear, GitHub, Slack, MCP, etc.)** — \`verify-built-workflow\` with \`inputData\` matching the trigger's expected payload shape.
|
||||
- If verification fails, call \`executions(action="debug")\`, fix the code, re-submit, and retry once
|
||||
- If the same failure signature repeats, stop and explain the block
|
||||
|
||||
@@ -314,25 +309,6 @@ export async function startBuildWorkflowAgentTask(
|
||||
const taskId = input.taskId ?? `build-${nanoid(8)}`;
|
||||
const workItemId = `wi_${nanoid(8)}`;
|
||||
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'agent-spawned',
|
||||
runId: context.runId,
|
||||
agentId: subAgentId,
|
||||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role: 'workflow-builder',
|
||||
tools: Object.keys(builderTools),
|
||||
taskId,
|
||||
kind: 'builder',
|
||||
title: 'Building workflow',
|
||||
subtitle: truncateLabel(input.task),
|
||||
goal: input.task,
|
||||
targetResource: input.workflowId
|
||||
? { type: 'workflow' as const, id: input.workflowId }
|
||||
: { type: 'workflow' as const },
|
||||
},
|
||||
});
|
||||
|
||||
const { workflowId } = input;
|
||||
|
||||
// Build additional context based on sandbox mode and existing workflow
|
||||
@@ -373,7 +349,7 @@ export async function startBuildWorkflowAgentTask(
|
||||
},
|
||||
});
|
||||
|
||||
context.spawnBackgroundTask({
|
||||
const spawnOutcome = context.spawnBackgroundTask({
|
||||
taskId,
|
||||
threadId: context.threadId,
|
||||
agentId: subAgentId,
|
||||
@@ -381,6 +357,18 @@ export async function startBuildWorkflowAgentTask(
|
||||
traceContext,
|
||||
plannedTaskId: input.plannedTaskId,
|
||||
workItemId,
|
||||
dedupeKey: {
|
||||
role: 'workflow-builder',
|
||||
plannedTaskId: input.plannedTaskId,
|
||||
workflowId: input.workflowId,
|
||||
},
|
||||
// When the orchestrator spawns a builder inside a checkpoint follow-up
|
||||
// (e.g. to patch a runtime bug the verify exposed), tag the task so the
|
||||
// safety net doesn't pre-emptively fail the checkpoint and the
|
||||
// settlement path can re-enter the checkpoint context instead of a
|
||||
// bare background-task-completed shell.
|
||||
parentCheckpointId:
|
||||
context.isCheckpointFollowUp === true ? context.checkpointTaskId : undefined,
|
||||
run: async (signal, drainCorrections, waitForCorrection): Promise<BackgroundTaskResult> =>
|
||||
await withTraceContextActor(traceContext, async () => {
|
||||
let builderWs: BuilderWorkspace | undefined;
|
||||
@@ -483,6 +471,7 @@ export async function startBuildWorkflowAgentTask(
|
||||
const stream = await subAgent.stream(briefing, {
|
||||
maxSteps: MAX_STEPS.BUILDER,
|
||||
abortSignal: signal,
|
||||
modelSettings: { temperature: TEMPERATURE.BUILDER },
|
||||
providerOptions: {
|
||||
anthropic: { cacheControl: { type: 'ephemeral' } },
|
||||
},
|
||||
@@ -632,6 +621,7 @@ export async function startBuildWorkflowAgentTask(
|
||||
const stream = await subAgent.stream(briefing, {
|
||||
maxSteps: MAX_STEPS.BUILDER,
|
||||
abortSignal: signal,
|
||||
modelSettings: { temperature: TEMPERATURE.BUILDER },
|
||||
providerOptions: {
|
||||
anthropic: { cacheControl: { type: 'ephemeral' } },
|
||||
},
|
||||
@@ -667,6 +657,43 @@ export async function startBuildWorkflowAgentTask(
|
||||
}),
|
||||
});
|
||||
|
||||
if (spawnOutcome.status === 'duplicate') {
|
||||
return {
|
||||
result: `Workflow build already in progress (task: ${spawnOutcome.existing.taskId}). Acknowledge and wait for the planned-task-follow-up — do not dispatch again.`,
|
||||
taskId: spawnOutcome.existing.taskId,
|
||||
agentId: spawnOutcome.existing.agentId,
|
||||
};
|
||||
}
|
||||
if (spawnOutcome.status === 'limit-reached') {
|
||||
return {
|
||||
result:
|
||||
'Could not start build: concurrent background-task limit reached. Wait for an existing task to finish and try again.',
|
||||
taskId: '',
|
||||
agentId: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Spawn confirmed — publish the UI event now so duplicate/limit-reached
|
||||
// rejections above don't leave a phantom builder card on the chat surface.
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'agent-spawned',
|
||||
runId: context.runId,
|
||||
agentId: subAgentId,
|
||||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role: 'workflow-builder',
|
||||
tools: Object.keys(builderTools),
|
||||
taskId,
|
||||
kind: 'builder',
|
||||
title: 'Building workflow',
|
||||
subtitle: truncateLabel(input.task),
|
||||
goal: input.task,
|
||||
targetResource: input.workflowId
|
||||
? { type: 'workflow' as const, id: input.workflowId }
|
||||
: { type: 'workflow' as const },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
result: `Workflow build started (task: ${taskId}). Reply with one short sentence — e.g. name what's being built. Do NOT summarize the plan or list details.`,
|
||||
taskId,
|
||||
@@ -692,21 +719,95 @@ export const buildWorkflowAgentInputSchema = z.object({
|
||||
.describe(
|
||||
'Brief summary of the conversation so far — what was discussed, decisions made, and information gathered (e.g., which credentials are available). The builder uses this to avoid repeating information the user already knows.',
|
||||
),
|
||||
bypassPlan: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Set to true for any edit to an existing workflow — adding/removing/rewiring a node, changing an expression, swapping a credential, changing a schedule, fixing a Code node. Requires an existing `workflowId` and a one-sentence `reason`. The orchestrator verifies the result afterwards via `verify-built-workflow` when the trigger is mockable. ' +
|
||||
'A runtime guard rejects direct calls without `bypassPlan: true` outside replan/checkpoint follow-ups: new workflow builds, multi-workflow work, and data-table schema changes must go through `plan` so the build gets its orchestrator-run checkpoint.',
|
||||
),
|
||||
reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'One sentence explaining why the planner is being bypassed (e.g. "swap Slack channel on workflow X", "fix Code node shape issue"). Required when bypassPlan is true.',
|
||||
),
|
||||
});
|
||||
|
||||
/**
|
||||
* Replan / checkpoint follow-ups have already paid the planner's discovery cost
|
||||
* and carry the checkpoint task graph from the original plan — direct builder
|
||||
* calls in those contexts are legitimate (e.g. retry the one failing task).
|
||||
*/
|
||||
function isPostPlanFollowUp(context: OrchestrationContext): boolean {
|
||||
return context.isReplanFollowUp === true || context.isCheckpointFollowUp === true;
|
||||
}
|
||||
|
||||
function isBuildViaPlanGuardEnabled(): boolean {
|
||||
const raw = process.env.N8N_INSTANCE_AI_ENFORCE_BUILD_VIA_PLAN;
|
||||
if (raw === undefined) return true;
|
||||
return raw.toLowerCase() !== 'false' && raw !== '0';
|
||||
}
|
||||
|
||||
export function createBuildWorkflowAgentTool(context: OrchestrationContext) {
|
||||
return createTool({
|
||||
id: 'build-workflow-with-agent',
|
||||
description:
|
||||
'Build or modify an n8n workflow using a specialized builder agent. ' +
|
||||
'The agent handles node discovery, schema lookups, code generation, ' +
|
||||
'and validation internally.',
|
||||
'The agent handles node discovery, schema lookups, code generation, and validation internally. ' +
|
||||
'For edits to an existing workflow, call directly with `bypassPlan: true`, the existing `workflowId`, and a one-sentence `reason` — the orchestrator runs a lightweight verify afterwards. ' +
|
||||
'For new workflows, multi-workflow builds, or data-table schema changes, go through `plan` — ' +
|
||||
'a runtime guard rejects direct calls without `bypassPlan: true` outside replan/checkpoint follow-ups, because those paths need the orchestrator-run checkpoint for end-to-end verification.',
|
||||
inputSchema: buildWorkflowAgentInputSchema,
|
||||
outputSchema: z.object({
|
||||
result: z.string(),
|
||||
taskId: z.string(),
|
||||
}),
|
||||
execute: async (input: z.infer<typeof buildWorkflowAgentInputSchema>) => {
|
||||
if (isBuildViaPlanGuardEnabled() && !isPostPlanFollowUp(context)) {
|
||||
if (!input.bypassPlan) {
|
||||
context.logger.warn(
|
||||
'build-workflow-with-agent called outside plan/replan context — rejecting',
|
||||
{
|
||||
threadId: context.threadId,
|
||||
hasWorkflowId: Boolean(input.workflowId),
|
||||
},
|
||||
);
|
||||
return {
|
||||
result:
|
||||
'Error: direct builder calls require `bypassPlan: true` + an existing ' +
|
||||
'`workflowId` + a one-sentence `reason`. Use that combination for any edit to ' +
|
||||
'an existing workflow. For new workflows, multi-workflow builds, or data-table ' +
|
||||
'schema changes, call `plan` with a `build-workflow` task instead — the planner ' +
|
||||
'discovers credentials, data tables, and best practices, and schedules an ' +
|
||||
'orchestrator-run verification checkpoint.',
|
||||
taskId: '',
|
||||
};
|
||||
}
|
||||
if (!input.workflowId) {
|
||||
return {
|
||||
result:
|
||||
'Error: `bypassPlan: true` is for edits to an EXISTING workflow and requires a ' +
|
||||
'`workflowId`. New workflow builds must go through `plan` so an orchestrator-run ' +
|
||||
'verification checkpoint is scheduled. Call `plan` with a `build-workflow` task ' +
|
||||
'instead.',
|
||||
taskId: '',
|
||||
};
|
||||
}
|
||||
if (!input.reason || input.reason.trim().length === 0) {
|
||||
return {
|
||||
result:
|
||||
'Error: `bypassPlan: true` requires a one-sentence `reason` describing the edit ' +
|
||||
'(e.g. "swap Slack channel", "fix Code node shape issue").',
|
||||
taskId: '',
|
||||
};
|
||||
}
|
||||
context.logger.warn('build-workflow-with-agent bypassing plan with bypassPlan=true', {
|
||||
threadId: context.threadId,
|
||||
workflowId: input.workflowId,
|
||||
reason: input.reason,
|
||||
});
|
||||
}
|
||||
const result = await startBuildWorkflowAgentTask(context, input);
|
||||
return { result: result.result, taskId: result.taskId };
|
||||
},
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* complete-checkpoint tool — called by the orchestrator to settle a planned-task
|
||||
* checkpoint.
|
||||
*
|
||||
* The service enqueues an internal follow-up run carrying a checkpoint's spec.
|
||||
* The orchestrator executes the spec using its normal tools (verify-built-workflow,
|
||||
* executions(action="run"), etc.) and then MUST call this tool exactly once to
|
||||
* report the outcome. The service's post-run deadlock fallback guarantees
|
||||
* progress even if the orchestrator forgets.
|
||||
*/
|
||||
|
||||
import { createTool } from '@mastra/core/tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { OrchestrationContext } from '../../types';
|
||||
|
||||
const inputSchema = z.object({
|
||||
taskId: z.string().describe('The checkpoint task ID from the <planned-task-follow-up> payload'),
|
||||
status: z
|
||||
.enum(['succeeded', 'failed'])
|
||||
.describe('Whether the verification passed (succeeded) or failed (failed)'),
|
||||
result: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Short user-visible note describing the verification outcome'),
|
||||
error: z.string().optional().describe('Error message when status=failed'),
|
||||
outcome: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe('Structured outcome payload (e.g., executionId, failureNode, data excerpt)'),
|
||||
});
|
||||
|
||||
const outputSchema = z.object({
|
||||
result: z.string(),
|
||||
ok: z.boolean(),
|
||||
});
|
||||
|
||||
export function createCompleteCheckpointTool(context: OrchestrationContext) {
|
||||
return createTool({
|
||||
id: 'complete-checkpoint',
|
||||
description:
|
||||
'Report the outcome of a planned-task checkpoint you just executed. ' +
|
||||
'Call this exactly once per <planned-task-follow-up type="checkpoint"> block. ' +
|
||||
'Only valid for tasks of kind "checkpoint" that are currently running; ' +
|
||||
'calling with any other taskId returns an error and does not modify the graph.',
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
execute: async (input: z.infer<typeof inputSchema>) => {
|
||||
if (!context.plannedTaskService) {
|
||||
return { ok: false, result: 'Error: planned task service not available.' };
|
||||
}
|
||||
|
||||
const settleResult =
|
||||
input.status === 'succeeded'
|
||||
? await context.plannedTaskService.markCheckpointSucceeded(
|
||||
context.threadId,
|
||||
input.taskId,
|
||||
{
|
||||
result: input.result,
|
||||
outcome: input.outcome,
|
||||
},
|
||||
)
|
||||
: await context.plannedTaskService.markCheckpointFailed(context.threadId, input.taskId, {
|
||||
error: input.error ?? input.result ?? 'Checkpoint verification failed',
|
||||
// Preserve structured outcome (executionId, failureNode, data excerpt).
|
||||
// Without this, replans only see a flat error string and lose execution
|
||||
// context that would otherwise seed a targeted retry.
|
||||
outcome: input.outcome,
|
||||
});
|
||||
|
||||
if (settleResult.ok) {
|
||||
return {
|
||||
ok: true,
|
||||
result: `Checkpoint ${input.taskId} marked ${input.status}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const reason = settleResult.reason;
|
||||
if (reason === 'not-found') {
|
||||
return {
|
||||
ok: false,
|
||||
result: `Error: no task with id "${input.taskId}" exists in the current plan.`,
|
||||
};
|
||||
}
|
||||
if (reason === 'wrong-kind') {
|
||||
return {
|
||||
ok: false,
|
||||
result:
|
||||
`Error: task "${input.taskId}" is not a checkpoint ` +
|
||||
`(actual kind: ${settleResult.actual?.kind ?? 'unknown'}). ` +
|
||||
'complete-checkpoint can only settle checkpoint tasks.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
result:
|
||||
`Error: checkpoint "${input.taskId}" is not in running state ` +
|
||||
`(actual status: ${settleResult.actual?.status ?? 'unknown'}).`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -72,22 +72,6 @@ export async function startDataTableAgentTask(
|
||||
const subAgentId = input.agentId ?? `agent-datatable-${nanoid(6)}`;
|
||||
const taskId = input.taskId ?? `datatable-${nanoid(8)}`;
|
||||
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'agent-spawned',
|
||||
runId: context.runId,
|
||||
agentId: subAgentId,
|
||||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role: 'data-table-manager',
|
||||
tools: Object.keys(dataTableTools),
|
||||
taskId,
|
||||
kind: 'data-table',
|
||||
title: 'Managing data table',
|
||||
subtitle: truncateLabel(input.task),
|
||||
goal: input.task,
|
||||
targetResource: { type: 'data-table' as const },
|
||||
},
|
||||
});
|
||||
const traceContext = await createDetachedSubAgentTracing(context, {
|
||||
agentId: subAgentId,
|
||||
role: 'data-table-manager',
|
||||
@@ -101,13 +85,16 @@ export async function startDataTableAgentTask(
|
||||
});
|
||||
const tracedDataTableTools = traceSubAgentTools(context, dataTableTools, 'data-table-manager');
|
||||
|
||||
context.spawnBackgroundTask({
|
||||
const spawnOutcome = context.spawnBackgroundTask({
|
||||
taskId,
|
||||
threadId: context.threadId,
|
||||
agentId: subAgentId,
|
||||
role: 'data-table-manager',
|
||||
traceContext,
|
||||
plannedTaskId: input.plannedTaskId,
|
||||
dedupeKey: { role: 'data-table-manager', plannedTaskId: input.plannedTaskId },
|
||||
parentCheckpointId:
|
||||
context.isCheckpointFollowUp === true ? context.checkpointTaskId : undefined,
|
||||
run: async (signal, _drainCorrections, _waitForCorrection) => {
|
||||
return await withTraceContextActor(traceContext, async () => {
|
||||
const subAgent = new Agent({
|
||||
@@ -175,6 +162,41 @@ export async function startDataTableAgentTask(
|
||||
},
|
||||
});
|
||||
|
||||
if (spawnOutcome.status === 'duplicate') {
|
||||
return {
|
||||
result: `Data table operation already in progress (task: ${spawnOutcome.existing.taskId}). Wait for the planned-task-follow-up — do not dispatch again.`,
|
||||
taskId: spawnOutcome.existing.taskId,
|
||||
agentId: spawnOutcome.existing.agentId,
|
||||
};
|
||||
}
|
||||
if (spawnOutcome.status === 'limit-reached') {
|
||||
return {
|
||||
result:
|
||||
'Could not start data table operation: concurrent background-task limit reached. Wait for an existing task to finish and try again.',
|
||||
taskId: '',
|
||||
agentId: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Spawn confirmed — publish the UI event now so duplicate/limit-reached
|
||||
// rejections above don't leave a phantom card on the chat surface.
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'agent-spawned',
|
||||
runId: context.runId,
|
||||
agentId: subAgentId,
|
||||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role: 'data-table-manager',
|
||||
tools: Object.keys(dataTableTools),
|
||||
taskId,
|
||||
kind: 'data-table',
|
||||
title: 'Managing data table',
|
||||
subtitle: truncateLabel(input.task),
|
||||
goal: input.task,
|
||||
targetResource: { type: 'data-table' as const },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
result: `Data table operation started (task: ${taskId}). Do NOT summarize the plan or list details.`,
|
||||
taskId,
|
||||
|
||||
@@ -129,22 +129,6 @@ export async function startDetachedDelegateTask(
|
||||
const subAgentId = input.agentId ?? `agent-delegate-${nanoid(6)}`;
|
||||
const taskId = input.taskId ?? `delegate-${nanoid(8)}`;
|
||||
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'agent-spawned',
|
||||
runId: context.runId,
|
||||
agentId: subAgentId,
|
||||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role,
|
||||
tools: input.tools,
|
||||
taskId,
|
||||
kind: 'delegate',
|
||||
title: input.title,
|
||||
subtitle: truncateLabel(input.spec),
|
||||
goal: input.spec,
|
||||
},
|
||||
});
|
||||
|
||||
const briefingMessage = await buildDelegateBriefing(
|
||||
context,
|
||||
role,
|
||||
@@ -167,13 +151,16 @@ export async function startDetachedDelegateTask(
|
||||
});
|
||||
const tracedTools = traceSubAgentTools(context, validTools, role);
|
||||
|
||||
context.spawnBackgroundTask({
|
||||
const spawnOutcome = context.spawnBackgroundTask({
|
||||
taskId,
|
||||
threadId: context.threadId,
|
||||
agentId: subAgentId,
|
||||
role,
|
||||
traceContext,
|
||||
plannedTaskId: input.plannedTaskId,
|
||||
dedupeKey: { role, plannedTaskId: input.plannedTaskId },
|
||||
parentCheckpointId:
|
||||
context.isCheckpointFollowUp === true ? context.checkpointTaskId : undefined,
|
||||
run: async (signal, drainCorrections, waitForCorrection) => {
|
||||
return await withTraceContextActor(traceContext, async () => {
|
||||
const subAgent = createSubAgent({
|
||||
@@ -226,6 +213,40 @@ export async function startDetachedDelegateTask(
|
||||
},
|
||||
});
|
||||
|
||||
if (spawnOutcome.status === 'duplicate') {
|
||||
return {
|
||||
result: `Delegation already in progress (task: ${spawnOutcome.existing.taskId}). Wait for the planned-task-follow-up — do not dispatch again.`,
|
||||
taskId: spawnOutcome.existing.taskId,
|
||||
agentId: spawnOutcome.existing.agentId,
|
||||
};
|
||||
}
|
||||
if (spawnOutcome.status === 'limit-reached') {
|
||||
return {
|
||||
result:
|
||||
'Could not start delegation: concurrent background-task limit reached. Wait for an existing task to finish and try again.',
|
||||
taskId: '',
|
||||
agentId: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Spawn confirmed — publish the UI event now so duplicate/limit-reached
|
||||
// rejections above don't leave a phantom card on the chat surface.
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'agent-spawned',
|
||||
runId: context.runId,
|
||||
agentId: subAgentId,
|
||||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role,
|
||||
tools: input.tools,
|
||||
taskId,
|
||||
kind: 'delegate',
|
||||
title: input.title,
|
||||
subtitle: truncateLabel(input.spec),
|
||||
goal: input.spec,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
result: `Delegation started (task: ${taskId}). Do NOT summarize the plan or list details.`,
|
||||
taskId,
|
||||
|
||||
@@ -66,8 +66,14 @@ ${NATIVE_NODE_PREFERENCE}
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- **User time zone is in context as \`<current-datetime>\` / \`<user-timezone>\`.** Schedule times, cron expressions, and digest times must be stated in the user's time zone. Never write "instance default timezone" or leave the zone ambiguous — spell it out (e.g. "daily at 08:00 America/New_York").
|
||||
- **Dependencies are mandatory.** Every workflow must list the data table IDs it reads from or writes to in \`dependsOn\`. If workflow C needs data from A and B, it must depend on both.
|
||||
- **No duplicate items.** Each piece of work appears exactly once. Use \`workflow\` kind for workflows, \`data-table\` kind for all data table operations (create, delete, modify, seed), \`research\` kind for web research. Use \`delegate\` only for tasks that don't fit the other kinds — never for data table operations.
|
||||
- **Data-table-only plans are valid.** When the request is purely about data tables (no triggers, schedules, or integrations), use only \`data-table\` items — don't wrap them in \`workflow\` or \`delegate\`. For creation, include \`columns\`; for other operations, omit \`columns\` and describe the operation in \`purpose\`. Include seed rows in \`purpose\` when the user wants sample data.
|
||||
- **Each item's \`purpose\` describes only that item.** Do not reference work handled by other plan items — each agent only sees its own spec, and cross-task context causes scope creep.
|
||||
- **Workflow verification is mandatory.** For **every** \`workflow\` item you add, also add a \`checkpoint\` item whose \`dependsOn\` includes that workflow's ID. Checkpoints are orchestrator-executed — the orchestrator runs them itself using its own tools, they are not delegated.
|
||||
- \`title\`: a user-readable verification goal, e.g. \`"Verify 'Daily API Email' workflow runs successfully"\`.
|
||||
- \`instructions\`: detailed steps the orchestrator must execute. Prefer \`verify-built-workflow\` with the work item ID from the build outcome — it uses pin data captured at build time, so it works even for event-triggered workflows (webhook, form, chat, mcp). For workflows with real credentials and a testable trigger (manual, schedule), \`executions(action="run")\` is acceptable. State the pass condition in plain terms (e.g. "run completes without errors and produces at least one output row").
|
||||
- Do NOT list \`tools\` on a checkpoint — it is not a delegate task.
|
||||
- Do NOT emit a checkpoint for a \`data-table\`, \`research\`, or \`delegate\` item. Checkpoints are for workflows only.
|
||||
- **Always call \`submit-plan\` after the last \`add-plan-item\`.** On rejection, be surgical — change only what the user asked for. Never fabricate node names; search first if unsure.`;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { Agent } from '@mastra/core/agent';
|
||||
import type { ToolsInput } from '@mastra/core/agent';
|
||||
import { createTool } from '@mastra/core/tools';
|
||||
import { DateTime } from 'luxon';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -132,9 +133,20 @@ async function getRecentMessages(
|
||||
return messages;
|
||||
}
|
||||
|
||||
function formatMessagesForBriefing(messages: FormattedMessage[], guidance?: string): string {
|
||||
function formatMessagesForBriefing(
|
||||
messages: FormattedMessage[],
|
||||
guidance?: string,
|
||||
timeZone?: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
const now = timeZone ? DateTime.now().setZone(timeZone) : DateTime.now();
|
||||
const isoNow = now.toISO({ includeOffset: true }) ?? new Date().toISOString();
|
||||
parts.push(`<current-datetime>${isoNow}</current-datetime>`);
|
||||
if (timeZone) {
|
||||
parts.push(`<user-timezone>${timeZone}</user-timezone>`);
|
||||
}
|
||||
|
||||
if (messages.length > 0) {
|
||||
parts.push('## Recent conversation');
|
||||
for (const m of messages) {
|
||||
@@ -154,6 +166,8 @@ function formatMessagesForBriefing(messages: FormattedMessage[], guidance?: stri
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
export const __testFormatMessagesForBriefing = formatMessagesForBriefing;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: clear draft checklist from taskStorage
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -176,6 +190,36 @@ async function clearDraftChecklist(context: OrchestrationContext): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any persisted planned-task graph for this thread *if and only if* it
|
||||
* belongs to this planner run's unapproved plan. Called on planner give-up /
|
||||
* error paths to prevent a later schedulePlannedTasks() tick from dispatching
|
||||
* a plan the user never approved.
|
||||
*
|
||||
* Guarded because the thread may already carry an unrelated active graph (a
|
||||
* prior approved plan with pending checkpoints / in-flight tasks); an
|
||||
* unconditional `clear()` here would strand that work. We only touch the graph
|
||||
* when its `planRunId` matches this run AND its `status` is `awaiting_approval`
|
||||
* — the single window where submit-plan has persisted but approval hasn't
|
||||
* happened yet.
|
||||
*/
|
||||
export async function __testClearPlannedTaskGraph(context: OrchestrationContext): Promise<void> {
|
||||
return await clearPlannedTaskGraph(context);
|
||||
}
|
||||
|
||||
async function clearPlannedTaskGraph(context: OrchestrationContext): Promise<void> {
|
||||
if (!context.plannedTaskService) return;
|
||||
try {
|
||||
const graph = await context.plannedTaskService.getGraph(context.threadId);
|
||||
if (!graph) return;
|
||||
if (graph.planRunId !== context.runId) return;
|
||||
if (graph.status !== 'awaiting_approval') return;
|
||||
await context.plannedTaskService.clear(context.threadId);
|
||||
} catch {
|
||||
// Best-effort — don't let cleanup failures block the return path
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tool factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -227,7 +271,7 @@ export function createPlanWithAgentTool(context: OrchestrationContext) {
|
||||
|
||||
// ── Retrieve conversation history ─────────────────────────────
|
||||
const messages = await getRecentMessages(context, MESSAGE_HISTORY_COUNT);
|
||||
const briefing = formatMessagesForBriefing(messages, input.guidance);
|
||||
const briefing = formatMessagesForBriefing(messages, input.guidance, context.timeZone);
|
||||
|
||||
// ── IDs & events ──────────────────────────────────────────────
|
||||
const subAgentId = `agent-planner-${nanoid(6)}`;
|
||||
@@ -337,7 +381,12 @@ export function createPlanWithAgentTool(context: OrchestrationContext) {
|
||||
|
||||
// ── Schedule tasks after planner-driven approval ──────────
|
||||
// Only dispatch if submit-plan was called AND the user approved.
|
||||
// createPlan persists the graph as `awaiting_approval`; flip it
|
||||
// to `active` before scheduling so tick() can dispatch.
|
||||
if (accumulator.isApproved()) {
|
||||
if (context.plannedTaskService) {
|
||||
await context.plannedTaskService.approvePlan(context.threadId);
|
||||
}
|
||||
if (context.schedulePlannedTasks) {
|
||||
await context.schedulePlannedTasks();
|
||||
}
|
||||
@@ -350,6 +399,11 @@ export function createPlanWithAgentTool(context: OrchestrationContext) {
|
||||
// Planner finished without approval (no submit-plan or user didn't approve)
|
||||
publishClearingEvent(context);
|
||||
await clearDraftChecklist(context);
|
||||
// Clear the persisted planned-task graph too. submit-plan persists
|
||||
// it BEFORE user approval (so HITL can display the checklist), so
|
||||
// leaving it intact on planner give-up would let a later
|
||||
// schedulePlannedTasks() tick pick up and dispatch a rejected plan.
|
||||
await clearPlannedTaskGraph(context);
|
||||
if (!accumulator.isEmpty()) {
|
||||
return {
|
||||
result: `Planner added ${accumulator.getTaskList().length} items but did not submit the plan for approval. The plan was not executed.`,
|
||||
@@ -376,9 +430,17 @@ export function createPlanWithAgentTool(context: OrchestrationContext) {
|
||||
},
|
||||
});
|
||||
|
||||
// Clear draft checklist on error
|
||||
publishClearingEvent(context);
|
||||
await clearDraftChecklist(context);
|
||||
// Clear draft checklist and persisted graph on error — same reason
|
||||
// as the non-approval path: an error-aborted plan must not later be
|
||||
// auto-dispatched by the post-run reschedule. Skip both when the user
|
||||
// already approved this plan: the failure is downstream of approval
|
||||
// (e.g. approvePlan/schedulePlannedTasks threw), and clearing would
|
||||
// drop a plan the user explicitly accepted.
|
||||
if (!accumulator.isApproved()) {
|
||||
publishClearingEvent(context);
|
||||
await clearDraftChecklist(context);
|
||||
await clearPlannedTaskGraph(context);
|
||||
}
|
||||
|
||||
return { result: `Planner error: ${errorMessage}` };
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { OrchestrationContext, PlannedTask } from '../../types';
|
||||
const plannedTaskSchema = z.object({
|
||||
id: z.string().describe('Stable task identifier used by dependency edges'),
|
||||
title: z.string().describe('Short user-facing task title'),
|
||||
kind: z.enum(['delegate', 'build-workflow', 'manage-data-tables', 'research']),
|
||||
kind: z.enum(['delegate', 'build-workflow', 'manage-data-tables', 'research', 'checkpoint']),
|
||||
spec: z.string().describe('Detailed executor briefing for this task'),
|
||||
deps: z
|
||||
.array(z.string())
|
||||
@@ -54,12 +54,21 @@ function isReplanContext(context: OrchestrationContext): boolean {
|
||||
* `cancelled`) must not bypass the guard — a fresh user request on a long-
|
||||
* lived thread needs to go through `plan` for discovery, same as any first
|
||||
* request.
|
||||
*
|
||||
* `awaiting_approval` is treated as "existing" only within the run that
|
||||
* created it. The rejection path leaves the graph in `awaiting_approval` so a
|
||||
* same-turn revision can call `create-tasks` again, but an orphaned
|
||||
* `awaiting_approval` graph from a previous turn (the LLM never revised after
|
||||
* a rejection) must not bypass planner discovery for a fresh user request.
|
||||
*/
|
||||
async function threadHasExistingPlan(context: OrchestrationContext): Promise<boolean> {
|
||||
if (!context.plannedTaskService) return false;
|
||||
try {
|
||||
const graph = await context.plannedTaskService.getGraph(context.threadId);
|
||||
if (!graph) return false;
|
||||
if (graph.status === 'awaiting_approval') {
|
||||
return graph.planRunId === context.runId;
|
||||
}
|
||||
return graph.status === 'active' || graph.status === 'awaiting_replan';
|
||||
} catch {
|
||||
return false;
|
||||
@@ -189,8 +198,10 @@ export function createPlanTool(context: OrchestrationContext) {
|
||||
return { result: 'Awaiting approval', taskCount: input.tasks.length };
|
||||
}
|
||||
|
||||
// User approved — start execution
|
||||
// User approved — flip graph status from awaiting_approval → active,
|
||||
// then start execution.
|
||||
if (resumeData.approved) {
|
||||
await context.plannedTaskService.approvePlan(context.threadId);
|
||||
await context.schedulePlannedTasks();
|
||||
return {
|
||||
result: `Plan approved. Started ${input.tasks.length} task${input.tasks.length === 1 ? '' : 's'}.`,
|
||||
@@ -198,7 +209,26 @@ export function createPlanTool(context: OrchestrationContext) {
|
||||
};
|
||||
}
|
||||
|
||||
// User rejected or requested changes — return feedback to LLM
|
||||
// User rejected or requested changes. Reset the UI checklist so the
|
||||
// rejected plan's "todo" items don't linger on screen, but keep the
|
||||
// persisted graph in `awaiting_approval` so the LLM's next
|
||||
// `create-tasks` revision passes the replan guard via
|
||||
// `threadHasExistingPlan` (scoped to the current runId). The
|
||||
// scheduler ignores `awaiting_approval` graphs, so leaving the graph
|
||||
// in place can't dispatch the rejected plan; the next createPlan
|
||||
// call overwrites it with the revised tasks.
|
||||
// Best-effort: a storage failure here must not abort the revision flow.
|
||||
try {
|
||||
await context.taskStorage.save(context.threadId, { tasks: [] });
|
||||
} catch (error) {
|
||||
context.logger.warn('Failed to clear rejected plan checklist', { error });
|
||||
}
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'tasks-update',
|
||||
runId: context.runId,
|
||||
agentId: context.orchestratorAgentId,
|
||||
payload: { tasks: { tasks: [] }, planItems: [] },
|
||||
});
|
||||
return {
|
||||
result: `User requested changes: ${resumeData.userInput ?? 'No feedback provided'}. Revise the tasks and call create-tasks again.`,
|
||||
taskCount: 0,
|
||||
|
||||
@@ -66,22 +66,6 @@ export async function startResearchAgentTask(
|
||||
const subAgentId = input.agentId ?? `agent-researcher-${nanoid(6)}`;
|
||||
const taskId = input.taskId ?? `research-${nanoid(8)}`;
|
||||
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'agent-spawned',
|
||||
runId: context.runId,
|
||||
agentId: subAgentId,
|
||||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role: 'web-researcher',
|
||||
tools: Object.keys(researchTools),
|
||||
taskId,
|
||||
kind: 'researcher',
|
||||
title: 'Researching',
|
||||
subtitle: truncateLabel(input.goal),
|
||||
goal: input.goal,
|
||||
},
|
||||
});
|
||||
|
||||
const briefing = await buildSubAgentBriefing({
|
||||
task: input.goal,
|
||||
conversationContext: input.conversationContext,
|
||||
@@ -102,13 +86,16 @@ export async function startResearchAgentTask(
|
||||
});
|
||||
const tracedResearchTools = traceSubAgentTools(context, researchTools, 'web-researcher');
|
||||
|
||||
context.spawnBackgroundTask({
|
||||
const spawnOutcome = context.spawnBackgroundTask({
|
||||
taskId,
|
||||
threadId: context.threadId,
|
||||
agentId: subAgentId,
|
||||
role: 'web-researcher',
|
||||
traceContext,
|
||||
plannedTaskId: input.plannedTaskId,
|
||||
dedupeKey: { role: 'web-researcher', plannedTaskId: input.plannedTaskId },
|
||||
parentCheckpointId:
|
||||
context.isCheckpointFollowUp === true ? context.checkpointTaskId : undefined,
|
||||
run: async (signal, drainCorrections, waitForCorrection) => {
|
||||
return await withTraceContextActor(traceContext, async () => {
|
||||
const subAgent = new Agent({
|
||||
@@ -168,6 +155,40 @@ export async function startResearchAgentTask(
|
||||
},
|
||||
});
|
||||
|
||||
if (spawnOutcome.status === 'duplicate') {
|
||||
return {
|
||||
result: `Research already in progress (task: ${spawnOutcome.existing.taskId}). Wait for the planned-task-follow-up — do not dispatch again.`,
|
||||
taskId: spawnOutcome.existing.taskId,
|
||||
agentId: spawnOutcome.existing.agentId,
|
||||
};
|
||||
}
|
||||
if (spawnOutcome.status === 'limit-reached') {
|
||||
return {
|
||||
result:
|
||||
'Could not start research: concurrent background-task limit reached. Wait for an existing task to finish and try again.',
|
||||
taskId: '',
|
||||
agentId: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Spawn confirmed — publish the UI event now so duplicate/limit-reached
|
||||
// rejections above don't leave a phantom card on the chat surface.
|
||||
context.eventBus.publish(context.threadId, {
|
||||
type: 'agent-spawned',
|
||||
runId: context.runId,
|
||||
agentId: subAgentId,
|
||||
payload: {
|
||||
parentId: context.orchestratorAgentId,
|
||||
role: 'web-researcher',
|
||||
tools: Object.keys(researchTools),
|
||||
taskId,
|
||||
kind: 'researcher',
|
||||
title: 'Researching',
|
||||
subtitle: truncateLabel(input.goal),
|
||||
goal: input.goal,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
result: `Research started (task: ${taskId}). Do NOT summarize the plan or list details.`,
|
||||
taskId,
|
||||
|
||||
@@ -9,12 +9,255 @@
|
||||
import { createTool } from '@mastra/core/tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { OrchestrationContext } from '../../types';
|
||||
import type { Logger } from '../../logger';
|
||||
import type {
|
||||
InstanceAiDataTableService,
|
||||
InstanceAiWorkflowService,
|
||||
OrchestrationContext,
|
||||
} from '../../types';
|
||||
|
||||
interface DataTableWriteNode {
|
||||
nodeName: string;
|
||||
dataTableId: string;
|
||||
/**
|
||||
* Only `insert` is cleaned up post-verify. `upsert` is tracked but never
|
||||
* cleaned up because its node output cannot distinguish a newly-created
|
||||
* row from a match on an existing row (see cleanupInsertedRowsByNodeOutput).
|
||||
* `update` never creates rows so cleanup is moot.
|
||||
*/
|
||||
operation: 'insert' | 'upsert' | 'update';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the data-table write nodes a workflow contains, keyed by node name so
|
||||
* we can look up each node's per-execution output and identify the exact row IDs
|
||||
* it created. Returning node-level records (instead of just dataTable IDs) is
|
||||
* what lets post-verify cleanup delete only rows *this* run inserted — rows from
|
||||
* concurrent writers never appear in these nodes' outputs, so they are safe.
|
||||
*/
|
||||
async function extractDataTableWriteNodes(
|
||||
workflowService: InstanceAiWorkflowService,
|
||||
workflowId: string,
|
||||
): Promise<DataTableWriteNode[]> {
|
||||
try {
|
||||
const json = await workflowService.getAsWorkflowJSON(workflowId);
|
||||
const out: DataTableWriteNode[] = [];
|
||||
for (const node of json.nodes ?? []) {
|
||||
if (node.type !== 'n8n-nodes-base.dataTable') continue;
|
||||
const params = node.parameters as Record<string, unknown> | undefined;
|
||||
const operation = params?.operation;
|
||||
if (operation !== 'insert' && operation !== 'upsert' && operation !== 'update') continue;
|
||||
const ref = params?.dataTableId;
|
||||
let dataTableId: string | undefined;
|
||||
if (typeof ref === 'string' && ref.length > 0) {
|
||||
dataTableId = ref;
|
||||
} else if (
|
||||
ref &&
|
||||
typeof ref === 'object' &&
|
||||
'value' in ref &&
|
||||
typeof (ref as { value: unknown }).value === 'string'
|
||||
) {
|
||||
const value = (ref as { value: string }).value;
|
||||
if (value.length > 0) dataTableId = value;
|
||||
}
|
||||
if (!dataTableId || !node.name) continue;
|
||||
out.push({ nodeName: node.name, dataTableId, operation });
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the numeric `id` values that a single node produced as output during
|
||||
* the verify execution. Handles the common n8n shapes: an array of row objects,
|
||||
* a `{ json: {...} }` wrapper, or a single row object.
|
||||
*/
|
||||
function extractRowIdsFromNodeOutput(nodeOutput: unknown): number[] {
|
||||
const ids: number[] = [];
|
||||
const visit = (value: unknown): void => {
|
||||
if (!value) return;
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) visit(item);
|
||||
return;
|
||||
}
|
||||
if (typeof value !== 'object') return;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (row.json !== undefined) {
|
||||
visit(row.json);
|
||||
return;
|
||||
}
|
||||
const id = row.id;
|
||||
if (typeof id === 'number' && Number.isFinite(id)) ids.push(id);
|
||||
};
|
||||
visit(nodeOutput);
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-table pre-verify snapshot. A `Set` is a complete list of row IDs that
|
||||
* existed before the run. `null` means the snapshot could not be built (empty
|
||||
* table, read error, or pagination cap hit) — cleanup skips any table with a
|
||||
* null snapshot. The snapshot guards insert-node cleanup against pathological
|
||||
* outputs (e.g. an insert node returning an existing row ID); upsert outputs
|
||||
* are not eligible for cleanup at all.
|
||||
*/
|
||||
type PreIdsMap = Map<string, Set<number> | null>;
|
||||
|
||||
/** Rows per page when snapshotting table contents. */
|
||||
const SNAPSHOT_PAGE_SIZE = 1000;
|
||||
/**
|
||||
* Hard cap on total rows we will snapshot per table. Snapshot is only a safety
|
||||
* check (`is this ID pre-existing?`) so on tables above this size we disable
|
||||
* cleanup rather than keep paging forever.
|
||||
*/
|
||||
const SNAPSHOT_MAX_ROWS = 100_000;
|
||||
|
||||
/**
|
||||
* Delete rows this verify execution inserted, identified by the node outputs of
|
||||
* the dataTable insert nodes the workflow contains. Rows inserted by concurrent
|
||||
* writers never appear in an insert node's output and are therefore safe.
|
||||
*
|
||||
* Upsert nodes are deliberately skipped: their node output cannot distinguish
|
||||
* a newly-created row from a match on an existing one. A concurrent writer
|
||||
* inserting a row between the snapshot and the upsert call could yield an ID
|
||||
* that looks "new" to the ID-diff check while actually belonging to the
|
||||
* concurrent writer — deleting it would destroy production data. Until the
|
||||
* upsert path exposes a `wasCreated` flag in the row return, we trade leaking
|
||||
* a few verify-created rows for guaranteed safety.
|
||||
*
|
||||
* When `preIdsByTable.get(dataTableId)` is `null` the snapshot could not be
|
||||
* built and cleanup is skipped for that table; without a reliable pre-existing
|
||||
* set we cannot distinguish a new insert from a row that pre-existed.
|
||||
*/
|
||||
async function cleanupInsertedRowsByNodeOutput(
|
||||
dataTableService: InstanceAiDataTableService,
|
||||
writeNodes: DataTableWriteNode[],
|
||||
resultData: Record<string, unknown> | undefined,
|
||||
preIdsByTable: PreIdsMap,
|
||||
logger: Logger,
|
||||
): Promise<number> {
|
||||
if (!resultData) return 0;
|
||||
/** per-table set of row IDs the workflow's own insert nodes produced */
|
||||
const createdIdsByTable = new Map<string, Set<number>>();
|
||||
for (const { nodeName, dataTableId, operation } of writeNodes) {
|
||||
if (operation !== 'insert') continue;
|
||||
const output = resultData[nodeName];
|
||||
if (!output) continue;
|
||||
const ids = extractRowIdsFromNodeOutput(output);
|
||||
if (ids.length === 0) continue;
|
||||
let bucket = createdIdsByTable.get(dataTableId);
|
||||
if (!bucket) {
|
||||
bucket = new Set();
|
||||
createdIdsByTable.set(dataTableId, bucket);
|
||||
}
|
||||
for (const id of ids) bucket.add(id);
|
||||
}
|
||||
let total = 0;
|
||||
for (const [dataTableId, ids] of createdIdsByTable) {
|
||||
const preIds = preIdsByTable.get(dataTableId);
|
||||
if (preIds === undefined || preIds === null) {
|
||||
logger.warn(
|
||||
'Skipping data-table cleanup: pre-verify snapshot unavailable. Rows left in place to avoid deleting existing data.',
|
||||
{ dataTableId, candidateIds: ids.size },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// Only delete IDs that did not exist before the run — upsert-matched rows stay.
|
||||
const toDelete = [...ids].filter((id) => !preIds.has(id));
|
||||
if (toDelete.length === 0) continue;
|
||||
try {
|
||||
await dataTableService.deleteRows(dataTableId, {
|
||||
type: 'or',
|
||||
filters: toDelete.map((id) => ({
|
||||
columnName: 'id',
|
||||
condition: 'eq' as const,
|
||||
value: id,
|
||||
})),
|
||||
});
|
||||
total += toDelete.length;
|
||||
} catch {
|
||||
// best-effort: failure on one table does not block others
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-verify snapshot of current row IDs per tracked table. Used as a defensive
|
||||
* filter for insert-node cleanup — any output ID present in the pre-snapshot
|
||||
* is left alone, never deleted. The delete set is still driven by node output,
|
||||
* not by a post-verify table-wide diff, so concurrent writers stay safe.
|
||||
*
|
||||
* The snapshot pages through the full table because the cap on `queryRows`
|
||||
* would otherwise leave existing rows past the first page unprotected. If
|
||||
* pagination fails mid-way or the table is bigger than `SNAPSHOT_MAX_ROWS`,
|
||||
* the entry is set to `null` so `cleanupInsertedRowsByNodeOutput` skips that
|
||||
* table rather than guess.
|
||||
*/
|
||||
async function snapshotRowIdsPerTable(
|
||||
dataTableService: InstanceAiDataTableService,
|
||||
dataTableIds: Iterable<string>,
|
||||
logger: Logger,
|
||||
): Promise<PreIdsMap> {
|
||||
const out: PreIdsMap = new Map();
|
||||
for (const id of dataTableIds) {
|
||||
try {
|
||||
const bucket = new Set<number>();
|
||||
let offset = 0;
|
||||
let truncated = false;
|
||||
for (;;) {
|
||||
const { data } = await dataTableService.queryRows(id, {
|
||||
limit: SNAPSHOT_PAGE_SIZE,
|
||||
offset,
|
||||
});
|
||||
for (const row of data) {
|
||||
const rid = row.id;
|
||||
if (typeof rid === 'number') bucket.add(rid);
|
||||
}
|
||||
if (data.length < SNAPSHOT_PAGE_SIZE) break;
|
||||
offset += SNAPSHOT_PAGE_SIZE;
|
||||
if (offset >= SNAPSHOT_MAX_ROWS) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (truncated) {
|
||||
logger.warn(
|
||||
'Data-table pre-verify snapshot exceeded row cap — cleanup disabled for this table',
|
||||
{ dataTableId: id, cap: SNAPSHOT_MAX_ROWS },
|
||||
);
|
||||
out.set(id, null);
|
||||
} else {
|
||||
out.set(id, bucket);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Data-table pre-verify snapshot failed — cleanup disabled for this table', {
|
||||
dataTableId: id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
out.set(id, null);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const verifyBuiltWorkflowInputSchema = z.object({
|
||||
workItemId: z.string().describe('The work item ID from the build (wi_XXXXXXXX)'),
|
||||
workflowId: z.string().describe('The workflow ID to verify'),
|
||||
inputData: z.record(z.unknown()).optional().describe('Input data passed to the workflow trigger'),
|
||||
inputData: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe(
|
||||
"Input data for the workflow trigger. Shape MUST match the trigger's real-world output: " +
|
||||
'Form Trigger → flat field map like {name: "Alice", email: "a@b.c"} (do NOT wrap in formFields); ' +
|
||||
'Webhook → the body payload like {event: "signup", userId: "..."} (adapter wraps it under body); ' +
|
||||
'Chat Trigger → {chatInput: "user message"}; ' +
|
||||
'Schedule Trigger → omit inputData. ' +
|
||||
"If you wrap a form payload in {formFields: {...}} the adapter will reject the call — the builder's " +
|
||||
'downstream expressions reference $json.<field>, matching the flat production shape.',
|
||||
),
|
||||
timeout: z
|
||||
.number()
|
||||
.int()
|
||||
@@ -29,7 +272,10 @@ export function createVerifyBuiltWorkflowTool(context: OrchestrationContext) {
|
||||
id: 'verify-built-workflow',
|
||||
description:
|
||||
'Run a built workflow that has mocked credentials, using sidecar verification pin data ' +
|
||||
'from the build outcome. Use this instead of `executions(action="run")` when the build had mocked credentials.',
|
||||
'from the build outcome. Use this instead of `executions(action="run")` when the build had mocked credentials. ' +
|
||||
'CRITICAL: `inputData` shape depends on the trigger type — see the per-trigger guidance on the inputData field. ' +
|
||||
'Passing the wrong shape (e.g. wrapping form fields under `formFields`) produces null downstream values that ' +
|
||||
'look like an expression bug but are not — do not patch the workflow, re-run verify with the correct shape.',
|
||||
inputSchema: verifyBuiltWorkflowInputSchema,
|
||||
outputSchema: z.object({
|
||||
executionId: z.string().optional(),
|
||||
@@ -51,6 +297,23 @@ export function createVerifyBuiltWorkflowTool(context: OrchestrationContext) {
|
||||
};
|
||||
}
|
||||
|
||||
// Pre-verify: enumerate the dataTable write nodes in the workflow and
|
||||
// snapshot current row IDs for each insert-touched table. The delete
|
||||
// set comes from each insert node's own output after verify (so
|
||||
// concurrent writers stay invisible to cleanup); the snapshot is a
|
||||
// defensive filter so any ID that pre-existed cannot be deleted.
|
||||
// Upsert outputs are deliberately never cleaned — see
|
||||
// `cleanupInsertedRowsByNodeOutput` for the rationale.
|
||||
const writeNodes = await extractDataTableWriteNodes(
|
||||
context.domainContext.workflowService,
|
||||
input.workflowId,
|
||||
);
|
||||
const preSnapshots = await snapshotRowIdsPerTable(
|
||||
context.domainContext.dataTableService,
|
||||
new Set(writeNodes.map((n) => n.dataTableId)),
|
||||
context.logger,
|
||||
);
|
||||
|
||||
const result = await context.domainContext.executionService.run(
|
||||
input.workflowId,
|
||||
input.inputData,
|
||||
@@ -60,9 +323,62 @@ export function createVerifyBuiltWorkflowTool(context: OrchestrationContext) {
|
||||
},
|
||||
);
|
||||
|
||||
// Treat `waiting` as success when the workflow produced output and recorded
|
||||
// no error. `waiting` is a terminal-ish state for several legitimate flows:
|
||||
// Form Trigger workflows that end on a form-respond / completion page, Wait
|
||||
// nodes, and HITL prompts. Considering it a failure caused builders to
|
||||
// falsely retry verified form workflows and prevented checkpoints from
|
||||
// reusing builder evidence. Only treat `waiting` with no output rows AND
|
||||
// no error as indeterminate (falls through to failure).
|
||||
const hasOutput = result.data ? Object.keys(result.data).length > 0 : false;
|
||||
const success =
|
||||
result.status === 'success' || (result.status === 'waiting' && !result.error && hasOutput);
|
||||
|
||||
// Post-verify cleanup: delete only rows this run's own dataTable insert
|
||||
// nodes emitted as output, and only those whose IDs were not present in
|
||||
// the pre-verify snapshot (protects upsert-updated rows from deletion).
|
||||
const cleanedRows = await cleanupInsertedRowsByNodeOutput(
|
||||
context.domainContext.dataTableService,
|
||||
writeNodes,
|
||||
result.data,
|
||||
preSnapshots,
|
||||
context.logger,
|
||||
);
|
||||
|
||||
// Persist a structured verification record onto the build outcome so the
|
||||
// checkpoint follow-up turn can reuse it instead of re-running verify.
|
||||
// Best-effort: swallow storage errors so they don't mask the verify result.
|
||||
try {
|
||||
const nodesExecuted = result.data ? Object.keys(result.data) : undefined;
|
||||
await context.workflowTaskService.updateBuildOutcome(input.workItemId, {
|
||||
verification: {
|
||||
attempted: true,
|
||||
success,
|
||||
executionId: result.executionId || undefined,
|
||||
status: result.status,
|
||||
failureSignature: success ? undefined : result.error,
|
||||
evidence: {
|
||||
nodesExecuted: nodesExecuted && nodesExecuted.length > 0 ? nodesExecuted : undefined,
|
||||
errorMessage: success ? undefined : result.error,
|
||||
},
|
||||
verifiedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// intentional: verification record persistence is advisory
|
||||
}
|
||||
|
||||
if (cleanedRows > 0) {
|
||||
context.logger.debug?.('verify-built-workflow: cleaned up inserted rows', {
|
||||
workItemId: input.workItemId,
|
||||
workflowId: input.workflowId,
|
||||
cleanedRows,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
executionId: result.executionId || undefined,
|
||||
success: result.status === 'success',
|
||||
success,
|
||||
status: result.status,
|
||||
data: result.data,
|
||||
error: result.error,
|
||||
|
||||
+28
-1
@@ -1,7 +1,7 @@
|
||||
import type { Workspace } from '@mastra/core/workspace';
|
||||
|
||||
import type { InstanceAiContext } from '../../../types';
|
||||
import type { SubmitWorkflowAttempt } from '../submit-workflow.tool';
|
||||
import { isTriggerNodeType, type SubmitWorkflowAttempt } from '../submit-workflow.tool';
|
||||
|
||||
jest.mock('@mastra/core/tools', () => ({
|
||||
createTool: jest.fn((config: Record<string, unknown>) => config),
|
||||
@@ -46,6 +46,33 @@ function makeWorkspace(): Workspace {
|
||||
} as unknown as Workspace;
|
||||
}
|
||||
|
||||
describe('isTriggerNodeType', () => {
|
||||
it.each([
|
||||
'n8n-nodes-base.webhook',
|
||||
'n8n-nodes-base.formTrigger',
|
||||
'n8n-nodes-base.scheduleTrigger',
|
||||
'@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
])('recognises known-mockable type %s', (type) => {
|
||||
expect(isTriggerNodeType(type)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'n8n-nodes-base.emailReadImapTrigger',
|
||||
'@n8n/n8n-nodes-langchain.mcpTrigger',
|
||||
'n8n-nodes-base.manualTrigger',
|
||||
'customNamespace.someCustomtrigger',
|
||||
])('recognises suffix-matched trigger type %s', (type) => {
|
||||
expect(isTriggerNodeType(type)).toBe(true);
|
||||
});
|
||||
|
||||
it.each(['n8n-nodes-base.slack', 'n8n-nodes-base.code', 'n8n-nodes-base.set', undefined, ''])(
|
||||
'returns false for non-trigger %s',
|
||||
(type) => {
|
||||
expect(isTriggerNodeType(type)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('createSubmitWorkflowTool — permission enforcement', () => {
|
||||
it('rejects create when createWorkflow is blocked and reports the attempt', async () => {
|
||||
const attempts: SubmitWorkflowAttempt[] = [];
|
||||
|
||||
@@ -28,8 +28,13 @@ export interface SubmitWorkflowAttempt {
|
||||
success: boolean;
|
||||
/** Workflow ID assigned by n8n after a successful save. */
|
||||
workflowId?: string;
|
||||
/** Node types of all trigger nodes in the submitted workflow. */
|
||||
triggerNodeTypes?: string[];
|
||||
/**
|
||||
* Trigger nodes in the submitted workflow, each carrying name + type.
|
||||
* Populated by a conservative detector (known-mockable allow-list plus
|
||||
* any node type ending in `Trigger`); surfaces to the build outcome so
|
||||
* the orchestrator can choose a `verify-built-workflow` `inputData` shape.
|
||||
*/
|
||||
triggerNodes?: Array<{ nodeName: string; nodeType: string }>;
|
||||
/** Node names whose credentials were mocked. */
|
||||
mockedNodeNames?: string[];
|
||||
/** Credential types that were mocked (not resolved to real credentials). */
|
||||
@@ -57,6 +62,32 @@ const WEBHOOK_NODE_TYPES = new Set([
|
||||
'@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Node types the bypassPlan post-build verify flow can exercise without user
|
||||
* approval (verify-built-workflow injects sidecar pin data matching each
|
||||
* trigger's production output shape). Kept in sync with the per-trigger
|
||||
* inputData shape block in the orchestrator system prompt.
|
||||
*/
|
||||
const KNOWN_MOCKABLE_TRIGGER_TYPES = new Set([
|
||||
'n8n-nodes-base.webhook',
|
||||
'n8n-nodes-base.formTrigger',
|
||||
'n8n-nodes-base.scheduleTrigger',
|
||||
'@n8n/n8n-nodes-langchain.chatTrigger',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a node's type should be surfaced in `SubmitWorkflowAttempt.triggerNodes`
|
||||
* so the orchestrator can decide if it can verify the build without user input.
|
||||
* Known-mockable types feed the post-build verify step directly; other `*Trigger`
|
||||
* suffix types are included for visibility but skipped by the verify step.
|
||||
* Exported for direct unit coverage.
|
||||
*/
|
||||
export function isTriggerNodeType(nodeType: string | undefined): boolean {
|
||||
if (!nodeType) return false;
|
||||
if (KNOWN_MOCKABLE_TRIGGER_TYPES.has(nodeType)) return true;
|
||||
return nodeType.endsWith('Trigger') || nodeType.endsWith('trigger');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure webhook nodes have a webhookId so n8n registers clean URL paths.
|
||||
* Without it, getNodeWebhookPath() falls back to encoding the node name
|
||||
@@ -378,10 +409,13 @@ export function createSubmitWorkflowTool(
|
||||
});
|
||||
}
|
||||
|
||||
const triggers = (json.nodes ?? []).filter(
|
||||
(n) => n.type?.endsWith?.('Trigger') || n.type?.endsWith?.('trigger'),
|
||||
);
|
||||
const triggerNodeTypes = triggers.map((t) => t.type).filter(Boolean);
|
||||
const triggerNodes = (json.nodes ?? [])
|
||||
.filter((n) => isTriggerNodeType(n.type))
|
||||
.map((n) => ({ nodeName: n.name, nodeType: n.type }))
|
||||
.filter(
|
||||
(t): t is { nodeName: string; nodeType: string } =>
|
||||
Boolean(t.nodeName) && Boolean(t.nodeType),
|
||||
);
|
||||
|
||||
// Scan node parameters for unresolved placeholder values
|
||||
const hasPlaceholders = (json.nodes ?? []).some((n) => hasPlaceholderDeep(n.parameters));
|
||||
@@ -389,7 +423,7 @@ export function createSubmitWorkflowTool(
|
||||
await reportAttempt({
|
||||
success: true,
|
||||
workflowId: savedId,
|
||||
triggerNodeTypes,
|
||||
triggerNodes,
|
||||
mockedNodeNames: hasMockedCredentials ? mockResult.mockedNodeNames : undefined,
|
||||
mockedCredentialTypes: hasMockedCredentials ? mockResult.mockedCredentialTypes : undefined,
|
||||
mockedCredentialsByNode: hasMockedCredentials
|
||||
|
||||
@@ -250,6 +250,29 @@ describe('TraceIndex', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should scan forward for a matching tool when requested', () => {
|
||||
const events: TraceEvent[] = [
|
||||
makeToolCall(1, 'orchestrator', 'credentials'),
|
||||
makeToolCall(2, 'orchestrator', 'build-workflow-with-agent'),
|
||||
makeToolCall(3, 'orchestrator', 'plan'),
|
||||
];
|
||||
|
||||
const index = new TraceIndex(events);
|
||||
|
||||
expect(index.nextMatching('orchestrator', 'plan')?.stepId).toBe(3);
|
||||
expect(index.nextMatching('orchestrator', 'credentials')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null from matching lookup when trace is exhausted or role is unknown', () => {
|
||||
const events: TraceEvent[] = [makeToolCall(1, 'orchestrator', 'search-nodes')];
|
||||
|
||||
const index = new TraceIndex(events);
|
||||
|
||||
expect(index.nextMatching('unknown-role', 'search-nodes')).toBeNull();
|
||||
expect(index.nextMatching('orchestrator', 'search-nodes')?.stepId).toBe(1);
|
||||
expect(index.nextMatching('orchestrator', 'another-tool')).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle interleaved orchestrator and sub-agent calls', () => {
|
||||
const events: TraceEvent[] = [
|
||||
makeToolCall(1, 'orchestrator', 'tool-a'),
|
||||
|
||||
@@ -1012,10 +1012,12 @@ function replayWrapTool(
|
||||
resumeSchema: tool.resumeSchema,
|
||||
requestContextSchema: tool.requestContextSchema,
|
||||
execute: async (input, context) => {
|
||||
const event = traceIndex.next(agentRole, tool.id);
|
||||
const remappedInput = idRemapper.remapInput(input);
|
||||
const event = traceIndex.nextMatching(agentRole, tool.id);
|
||||
const remappedInput: unknown = event ? idRemapper.remapInput(input) : input;
|
||||
const realOutput = await tool.execute!(remappedInput, context);
|
||||
idRemapper.learn(event.output, realOutput as Record<string, unknown>);
|
||||
if (event) {
|
||||
idRemapper.learn(event.output, realOutput as Record<string, unknown>);
|
||||
}
|
||||
return realOutput;
|
||||
},
|
||||
mastra: tool.mastra,
|
||||
@@ -1049,7 +1051,12 @@ function pureReplayWrapTool(
|
||||
resumeSchema: tool.resumeSchema,
|
||||
requestContextSchema: tool.requestContextSchema,
|
||||
execute: async (_input, _context) => {
|
||||
const event = traceIndex.next(agentRole, tool.id);
|
||||
const event = traceIndex.nextMatching(agentRole, tool.id);
|
||||
if (!event) {
|
||||
throw new Error(
|
||||
`No recorded output for pure-replay tool "${tool.id}" in role "${agentRole}"`,
|
||||
);
|
||||
}
|
||||
return await Promise.resolve(idRemapper.remapOutput(event.output));
|
||||
},
|
||||
mastra: tool.mastra,
|
||||
|
||||
@@ -92,6 +92,30 @@ export class TraceIndex {
|
||||
this.cursors.set(agentRole, cursor + 1);
|
||||
return event;
|
||||
}
|
||||
|
||||
nextMatching(agentRole: string, expectedToolName: string): ToolTraceEvent | null {
|
||||
const events = this.byRole.get(agentRole);
|
||||
const cursor = this.cursors.get(agentRole) ?? 0;
|
||||
|
||||
if (!events || cursor >= events.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const event = events[cursor];
|
||||
if (event.toolName === expectedToolName) {
|
||||
this.cursors.set(agentRole, cursor + 1);
|
||||
return event;
|
||||
}
|
||||
|
||||
for (let i = cursor + 1; i < events.length; i++) {
|
||||
if (events[i].toolName === expectedToolName) {
|
||||
this.cursors.set(agentRole, i + 1);
|
||||
return events[i];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── IdRemapper ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -370,6 +370,18 @@ export interface DataTableFilterInput {
|
||||
|
||||
// ── Data table service ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Optional disambiguator accepted by every id-based data-table service call.
|
||||
* When the orchestrator passes a table NAME instead of a UUID, the adapter's
|
||||
* resolver filters the name lookup to this project so collisions across
|
||||
* projects don't require the orchestrator to guess the right UUID. When the
|
||||
* orchestrator passes a UUID AND a mismatched `projectId`, the adapter rejects
|
||||
* the call (the resolver never silently drops `projectId`).
|
||||
*/
|
||||
export interface DataTableIdOptions {
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
export interface InstanceAiDataTableService {
|
||||
list(options?: { projectId?: string }): Promise<DataTableSummary[]>;
|
||||
create(
|
||||
@@ -377,30 +389,44 @@ export interface InstanceAiDataTableService {
|
||||
columns: Array<{ name: string; type: 'string' | 'number' | 'boolean' | 'date' }>,
|
||||
options?: { projectId?: string },
|
||||
): Promise<DataTableSummary>;
|
||||
delete(dataTableId: string): Promise<void>;
|
||||
getSchema(dataTableId: string): Promise<DataTableColumnInfo[]>;
|
||||
delete(dataTableId: string, options?: DataTableIdOptions): Promise<void>;
|
||||
getSchema(dataTableId: string, options?: DataTableIdOptions): Promise<DataTableColumnInfo[]>;
|
||||
addColumn(
|
||||
dataTableId: string,
|
||||
column: { name: string; type: 'string' | 'number' | 'boolean' | 'date' },
|
||||
options?: DataTableIdOptions,
|
||||
): Promise<DataTableColumnInfo>;
|
||||
deleteColumn(dataTableId: string, columnId: string): Promise<void>;
|
||||
renameColumn(dataTableId: string, columnId: string, newName: string): Promise<void>;
|
||||
deleteColumn(dataTableId: string, columnId: string, options?: DataTableIdOptions): Promise<void>;
|
||||
renameColumn(
|
||||
dataTableId: string,
|
||||
columnId: string,
|
||||
newName: string,
|
||||
options?: DataTableIdOptions,
|
||||
): Promise<void>;
|
||||
queryRows(
|
||||
dataTableId: string,
|
||||
options?: { filter?: DataTableFilterInput; limit?: number; offset?: number },
|
||||
options?: {
|
||||
filter?: DataTableFilterInput;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
projectId?: string;
|
||||
},
|
||||
): Promise<{ count: number; data: Array<Record<string, unknown>> }>;
|
||||
insertRows(
|
||||
dataTableId: string,
|
||||
rows: Array<Record<string, unknown>>,
|
||||
options?: DataTableIdOptions,
|
||||
): Promise<{ insertedCount: number; dataTableId: string; tableName: string; projectId: string }>;
|
||||
updateRows(
|
||||
dataTableId: string,
|
||||
filter: DataTableFilterInput,
|
||||
data: Record<string, unknown>,
|
||||
options?: DataTableIdOptions,
|
||||
): Promise<{ updatedCount: number; dataTableId: string; tableName: string; projectId: string }>;
|
||||
deleteRows(
|
||||
dataTableId: string,
|
||||
filter: DataTableFilterInput,
|
||||
options?: DataTableIdOptions,
|
||||
): Promise<{ deletedCount: number; dataTableId: string; tableName: string; projectId: string }>;
|
||||
}
|
||||
|
||||
@@ -538,6 +564,10 @@ export interface InstanceAiContext {
|
||||
localGatewayStatus?: LocalGatewayStatus;
|
||||
/** Per-action HITL permission overrides. When absent, tools default to requiring approval. */
|
||||
permissions?: InstanceAiPermissions;
|
||||
/** When set, `runWorkflow: 'always_allow'` only short-circuits HITL approval for these workflow IDs.
|
||||
* Used by checkpoint follow-up runs to scope the override to the workflows the checkpoint is
|
||||
* verifying — `executions(action="run")` on any other workflow still requires user approval. */
|
||||
allowedRunWorkflowIds?: ReadonlySet<string>;
|
||||
/** When true, the instance is in read-only mode (source control branchReadOnly). */
|
||||
branchReadOnly?: boolean;
|
||||
/** Human-readable hints about licensed features that are NOT available on this instance.
|
||||
@@ -576,7 +606,12 @@ export interface TaskStorage {
|
||||
|
||||
// ── Planned task graphs ─────────────────────────────────────────────────────
|
||||
|
||||
export type PlannedTaskKind = 'delegate' | 'build-workflow' | 'manage-data-tables' | 'research';
|
||||
export type PlannedTaskKind =
|
||||
| 'delegate'
|
||||
| 'build-workflow'
|
||||
| 'manage-data-tables'
|
||||
| 'research'
|
||||
| 'checkpoint';
|
||||
|
||||
export interface PlannedTask {
|
||||
id: string;
|
||||
@@ -602,7 +637,12 @@ export interface PlannedTaskRecord extends PlannedTask {
|
||||
finishedAt?: number;
|
||||
}
|
||||
|
||||
export type PlannedTaskGraphStatus = 'active' | 'awaiting_replan' | 'completed' | 'cancelled';
|
||||
export type PlannedTaskGraphStatus =
|
||||
| 'awaiting_approval'
|
||||
| 'active'
|
||||
| 'awaiting_replan'
|
||||
| 'completed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface PlannedTaskGraph {
|
||||
planRunId: string;
|
||||
@@ -614,6 +654,7 @@ export interface PlannedTaskGraph {
|
||||
export type PlannedTaskSchedulerAction =
|
||||
| { type: 'none'; graph: PlannedTaskGraph | null }
|
||||
| { type: 'dispatch'; graph: PlannedTaskGraph; tasks: PlannedTaskRecord[] }
|
||||
| { type: 'orchestrate-checkpoint'; graph: PlannedTaskGraph; tasks: PlannedTaskRecord[] }
|
||||
| { type: 'replan'; graph: PlannedTaskGraph; failedTask: PlannedTaskRecord }
|
||||
| { type: 'synthesize'; graph: PlannedTaskGraph };
|
||||
|
||||
@@ -644,13 +685,52 @@ export interface PlannedTaskService {
|
||||
taskId: string,
|
||||
update?: { error?: string; finishedAt?: number },
|
||||
): Promise<PlannedTaskGraph | null>;
|
||||
markCheckpointSucceeded(
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
update: { result?: string; outcome?: Record<string, unknown>; finishedAt?: number },
|
||||
): Promise<CheckpointSettleResult>;
|
||||
markCheckpointFailed(
|
||||
threadId: string,
|
||||
taskId: string,
|
||||
update: {
|
||||
error?: string;
|
||||
/** Structured verification outcome (executionId, failureNode, etc.) so
|
||||
* replans have execution context, not just a flat error string. */
|
||||
outcome?: Record<string, unknown>;
|
||||
finishedAt?: number;
|
||||
},
|
||||
): Promise<CheckpointSettleResult>;
|
||||
/** Rewind a running checkpoint back to `planned` after a scheduling race
|
||||
* prevented its follow-up from starting. Non-destructive — dependents are
|
||||
* untouched and the next tick re-emits `orchestrate-checkpoint`. */
|
||||
revertCheckpointToPlanned(threadId: string, taskId: string): Promise<CheckpointSettleResult>;
|
||||
tick(
|
||||
threadId: string,
|
||||
options?: { availableSlots?: number },
|
||||
): Promise<PlannedTaskSchedulerAction>;
|
||||
clear(threadId: string): Promise<void>;
|
||||
/** Transition an `awaiting_approval` graph → `active` after the user
|
||||
* approves the plan. No-op on any other status. */
|
||||
approvePlan(threadId: string): Promise<PlannedTaskGraph | null>;
|
||||
/** Revert an `awaiting_replan` or `completed` graph back to `active`. Used by
|
||||
* the service when a replan or synthesize follow-up couldn't start. */
|
||||
revertToActive(threadId: string): Promise<PlannedTaskGraph | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a guarded checkpoint settlement. The mutators only transition a task
|
||||
* when its kind is `checkpoint` AND its status is `running`, so callers can read
|
||||
* the `reason` to report a precise error back to the LLM.
|
||||
*/
|
||||
export type CheckpointSettleResult =
|
||||
| { ok: true; graph: PlannedTaskGraph }
|
||||
| {
|
||||
ok: false;
|
||||
reason: 'not-found' | 'wrong-kind' | 'wrong-status';
|
||||
actual?: { kind?: PlannedTaskKind; status?: PlannedTaskStatus };
|
||||
};
|
||||
|
||||
// ── MCP ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface McpServerConfig {
|
||||
@@ -796,6 +876,27 @@ export interface SpawnBackgroundTaskOptions {
|
||||
/** Unique work item ID for workflow loop tracking. When set, the service
|
||||
* uses the workflow loop controller to manage verify/repair transitions. */
|
||||
workItemId?: string;
|
||||
/**
|
||||
* Identity used for single-flight dedupe. When present, a spawn with the same
|
||||
* `plannedTaskId` (primary) or `role + workflowId` (fallback) as a currently-running
|
||||
* task returns `{ status: 'duplicate', existing }` instead of starting a new task.
|
||||
*/
|
||||
dedupeKey?: {
|
||||
plannedTaskId?: string;
|
||||
workflowId?: string;
|
||||
role: string;
|
||||
};
|
||||
/**
|
||||
* Link this background task to a running checkpoint in the planned-task
|
||||
* graph. Set when the orchestrator spawns a detached sub-agent (builder,
|
||||
* research, data-table, delegate) from inside a
|
||||
* `<planned-task-follow-up type="checkpoint">` turn. The post-run safety
|
||||
* net defers failing the checkpoint while a child with this id is still
|
||||
* running, and settlement re-emits the checkpoint follow-up when the last
|
||||
* child settles — so the orchestrator re-enters the checkpoint context
|
||||
* instead of a bare `<background-task-completed>` shell.
|
||||
*/
|
||||
parentCheckpointId?: string;
|
||||
run: (
|
||||
signal: AbortSignal,
|
||||
drainCorrections: () => string[],
|
||||
@@ -803,6 +904,22 @@ export interface SpawnBackgroundTaskOptions {
|
||||
) => Promise<string | BackgroundTaskResult>;
|
||||
}
|
||||
|
||||
/** Result of a {@link SpawnBackgroundTaskOptions} spawn. */
|
||||
export type SpawnBackgroundTaskResult =
|
||||
| { status: 'started'; taskId: string; agentId: string }
|
||||
| { status: 'limit-reached' }
|
||||
| {
|
||||
status: 'duplicate';
|
||||
/** The live background task that matched on `dedupeKey`. */
|
||||
existing: {
|
||||
taskId: string;
|
||||
agentId: string;
|
||||
role: string;
|
||||
plannedTaskId?: string;
|
||||
workItemId?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export interface WorkflowTaskService {
|
||||
reportBuildOutcome(outcome: WorkflowBuildOutcome): Promise<WorkflowLoopAction>;
|
||||
reportVerificationVerdict(verdict: VerificationResult): Promise<WorkflowLoopAction>;
|
||||
@@ -854,7 +971,7 @@ export interface OrchestrationContext {
|
||||
/** Webhook base URL for the n8n instance (e.g. http://localhost:5678/webhook) — used to construct webhook URLs for created workflows */
|
||||
webhookBaseUrl?: string;
|
||||
/** Spawn a detached background task that outlives the current orchestrator run */
|
||||
spawnBackgroundTask?: (opts: SpawnBackgroundTaskOptions) => void;
|
||||
spawnBackgroundTask?: (opts: SpawnBackgroundTaskOptions) => SpawnBackgroundTaskResult;
|
||||
/** Cancel a running background task by its ID */
|
||||
cancelBackgroundTask?: (taskId: string) => Promise<void>;
|
||||
/** Persist and inspect dependency-aware planned tasks for this thread. */
|
||||
@@ -876,6 +993,12 @@ export interface OrchestrationContext {
|
||||
* background task. Set by the host, not by user text — the create-tasks guard
|
||||
* reads this instead of substring-matching `currentUserMessage`. */
|
||||
isReplanFollowUp?: boolean;
|
||||
/** True when the current run was started to execute a planned-task checkpoint.
|
||||
* The orchestrator should run the checkpoint's spec and call complete-checkpoint. */
|
||||
isCheckpointFollowUp?: boolean;
|
||||
/** When isCheckpointFollowUp is true, the task ID of the checkpoint being executed.
|
||||
* Used by the post-run deadlock fallback in the service. */
|
||||
checkpointTaskId?: string;
|
||||
/** The domain context — gives sub-agent tools access to n8n services */
|
||||
domainContext?: InstanceAiContext;
|
||||
/** When true, research guidance may suggest planned research tasks and the builder gets web-search/fetch-url */
|
||||
@@ -909,7 +1032,12 @@ export interface CreateInstanceAgentOptions {
|
||||
memoryConfig: InstanceAiMemoryConfig;
|
||||
/** Pre-built Memory instance. When provided, `memoryConfig` is ignored for memory creation. */
|
||||
memory?: Memory;
|
||||
/** Workspace with sandbox for code execution. When provided, the agent gets execute_command tool. */
|
||||
/**
|
||||
* @deprecated Ignored by the orchestrator. Passing a workspace here used to auto-register
|
||||
* `mastra_workspace_*` tools on the orchestrator, which the LLM abused as a `sleep` primitive
|
||||
* and mis-routed for build-task polling. Sandbox access is now scoped to the workflow-builder
|
||||
* subagent via `builderSandboxFactory`; `orchestrationContext.workspace` still flows to it.
|
||||
*/
|
||||
workspace?: Workspace;
|
||||
/** When true, all tools are loaded eagerly (no ToolSearchProcessor). Workaround for Mastra bug where toModelOutput is not called for deferred tools. */
|
||||
disableDeferredTools?: boolean;
|
||||
|
||||
@@ -65,12 +65,55 @@ export type AttemptRecord = z.infer<typeof attemptRecordSchema>;
|
||||
|
||||
export const triggerTypeSchema = z.enum(['manual_or_testable', 'trigger_only']);
|
||||
|
||||
/**
|
||||
* Structured verification evidence the builder captures when it runs
|
||||
* `verify-built-workflow`. Downstream checkpoint runs read this and skip
|
||||
* running verify again when `success === true`.
|
||||
*/
|
||||
export const workflowVerificationEvidenceSchema = z.object({
|
||||
attempted: z.boolean(),
|
||||
success: z.boolean(),
|
||||
executionId: z.string().optional(),
|
||||
status: z.enum(['success', 'error', 'waiting', 'running', 'unknown']).optional(),
|
||||
failureSignature: z.string().optional(),
|
||||
evidence: z
|
||||
.object({
|
||||
nodesExecuted: z.array(z.string()).optional(),
|
||||
producedOutputRows: z.number().optional(),
|
||||
errorNodeName: z.string().optional(),
|
||||
errorMessage: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
verifiedAt: z.string().datetime().optional(),
|
||||
});
|
||||
|
||||
export type WorkflowVerificationEvidence = z.infer<typeof workflowVerificationEvidenceSchema>;
|
||||
|
||||
/**
|
||||
* Structured trigger descriptor for each trigger node in the submitted workflow.
|
||||
* The orchestrator uses `nodeType` to decide whether the bypassPlan post-build
|
||||
* flow can invoke `verify-built-workflow` (mockable types) or must defer to a
|
||||
* manual user test (polling / OAuth-bound triggers).
|
||||
*/
|
||||
export const triggerNodeDescriptorSchema = z.object({
|
||||
nodeName: z.string(),
|
||||
nodeType: z.string(),
|
||||
});
|
||||
|
||||
export type TriggerNodeDescriptor = z.infer<typeof triggerNodeDescriptorSchema>;
|
||||
|
||||
export const workflowBuildOutcomeSchema = z.object({
|
||||
workItemId: z.string(),
|
||||
taskId: z.string(),
|
||||
workflowId: z.string().optional(),
|
||||
submitted: z.boolean(),
|
||||
triggerType: triggerTypeSchema,
|
||||
/**
|
||||
* Trigger nodes in the submitted workflow. Populated on successful submits;
|
||||
* absent on failed or pre-submit outcomes. The orchestrator reads `nodeType`
|
||||
* to pick a `verify-built-workflow` `inputData` shape for bypassPlan builds.
|
||||
*/
|
||||
triggerNodes: z.array(triggerNodeDescriptorSchema).optional(),
|
||||
needsUserInput: z.boolean(),
|
||||
blockingReason: z.string().optional(),
|
||||
failureSignature: z.string().optional(),
|
||||
@@ -84,6 +127,13 @@ export const workflowBuildOutcomeSchema = z.object({
|
||||
verificationPinData: z.record(z.array(z.record(z.unknown()))).optional(),
|
||||
/** Whether any node parameters contain unresolved placeholder values. */
|
||||
hasUnresolvedPlaceholders: z.boolean().optional(),
|
||||
/**
|
||||
* Structured verification record from the most recent `verify-built-workflow` call
|
||||
* that executed inside the builder. Observability metadata only: checkpoints must
|
||||
* still run independent verification before completing — the builder's self-report
|
||||
* is a claim, not proof.
|
||||
*/
|
||||
verification: workflowVerificationEvidenceSchema.optional(),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Tight mocks so we can drive `createN8nSandbox` end-to-end in Jest without
|
||||
// touching real sandboxes, filesystems, or the Mastra runtime.
|
||||
jest.mock('@mastra/core/workspace', () => {
|
||||
class Workspace {
|
||||
sandbox: unknown;
|
||||
filesystem: unknown;
|
||||
constructor(opts: { sandbox: unknown; filesystem: unknown }) {
|
||||
this.sandbox = opts.sandbox;
|
||||
this.filesystem = opts.filesystem;
|
||||
}
|
||||
async init(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
}
|
||||
}
|
||||
class LocalSandbox {}
|
||||
class LocalFilesystem {}
|
||||
return { Workspace, LocalSandbox, LocalFilesystem };
|
||||
});
|
||||
|
||||
jest.mock('@mastra/daytona', () => ({ DaytonaSandbox: class {} }));
|
||||
jest.mock('@daytonaio/sdk', () => ({ Daytona: class {} }));
|
||||
|
||||
jest.mock('../daytona-filesystem', () => ({
|
||||
DaytonaFilesystem: class {
|
||||
constructor(public sandbox: unknown) {}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../n8n-sandbox-filesystem', () => ({
|
||||
N8nSandboxFilesystem: class {
|
||||
constructor(public sandbox: unknown) {}
|
||||
writeFile = jest.fn(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../n8n-sandbox-image-manager', () => ({
|
||||
N8nSandboxImageManager: class {
|
||||
getDockerfile() {
|
||||
return 'FROM node:20';
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
type MockN8nSandbox = { destroy: jest.Mock };
|
||||
const capturedSandboxes: MockN8nSandbox[] = [];
|
||||
|
||||
jest.mock('../n8n-sandbox-sandbox', () => ({
|
||||
N8nSandboxServiceSandbox: class {
|
||||
destroy = jest.fn(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
constructor(public opts: Record<string, unknown>) {
|
||||
capturedSandboxes.push(this as unknown as MockN8nSandbox);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('../sandbox-fs', () => ({
|
||||
runInSandbox: jest.fn(async () => await Promise.resolve({ exitCode: 0, stdout: '', stderr: '' })),
|
||||
writeFileViaSandbox: jest.fn(async () => {
|
||||
await Promise.resolve();
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('../sandbox-setup', () => ({
|
||||
getWorkspaceRoot: jest.fn(async () => await Promise.resolve('/workspace')),
|
||||
formatNodeCatalogLine: jest.fn((x: { name?: string }) => x.name ?? ''),
|
||||
setupSandboxWorkspace: jest.fn(async () => await Promise.resolve()),
|
||||
}));
|
||||
|
||||
import type { InstanceAiContext } from '../../types';
|
||||
import type { SandboxConfig } from '../create-workspace';
|
||||
|
||||
const { BuilderSandboxFactory } =
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
require('../builder-sandbox-factory') as typeof import('../builder-sandbox-factory');
|
||||
|
||||
function makeContext(): InstanceAiContext {
|
||||
return {
|
||||
nodeService: {
|
||||
listSearchable: jest.fn(async () => await Promise.resolve([{ name: 'node-a' }])),
|
||||
},
|
||||
} as unknown as InstanceAiContext;
|
||||
}
|
||||
|
||||
function makeConfig(): SandboxConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
provider: 'n8n-sandbox',
|
||||
serviceUrl: 'https://sandbox.example.com',
|
||||
apiKey: 'secret',
|
||||
} as SandboxConfig;
|
||||
}
|
||||
|
||||
describe('BuilderSandboxFactory.createN8nSandbox cleanup on failure', () => {
|
||||
beforeEach(() => {
|
||||
capturedSandboxes.length = 0;
|
||||
});
|
||||
|
||||
it('destroys the remote sandbox when a post-creation step throws', async () => {
|
||||
// Force `getWorkspaceRoot` to throw so the post-creation workspace setup
|
||||
// fails. Any step after `new N8nSandboxServiceSandbox(...)` that can throw
|
||||
// (workspace.init, getWorkspaceRoot, catalog write, SDK link) should
|
||||
// funnel through the same destroy path.
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const sandboxSetup = require('../sandbox-setup') as typeof import('../sandbox-setup');
|
||||
(sandboxSetup.getWorkspaceRoot as jest.Mock).mockRejectedValueOnce(new Error('setup boom'));
|
||||
|
||||
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
|
||||
|
||||
await expect(factory.create('b-1', makeContext())).rejects.toThrow('setup boom');
|
||||
|
||||
expect(capturedSandboxes).toHaveLength(1);
|
||||
expect(capturedSandboxes[0].destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('swallows destroy errors so the original failure is surfaced, not the cleanup error', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/consistent-type-imports
|
||||
const sandboxSetup = require('../sandbox-setup') as typeof import('../sandbox-setup');
|
||||
(sandboxSetup.getWorkspaceRoot as jest.Mock).mockRejectedValueOnce(new Error('setup boom'));
|
||||
|
||||
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
|
||||
const createPromise = factory.create('b-2', makeContext());
|
||||
|
||||
// Arrange: when createN8nSandbox tries to destroy after the error, that
|
||||
// call also throws. The user-facing error must still be the original.
|
||||
await expect(createPromise).rejects.toThrow('setup boom');
|
||||
|
||||
expect(capturedSandboxes).toHaveLength(1);
|
||||
// The next spawn should also destroy cleanly even after a prior destroy failed.
|
||||
capturedSandboxes[0].destroy.mockRejectedValueOnce(new Error('destroy also failed'));
|
||||
});
|
||||
|
||||
it('returns a cleanup handle that destroys the sandbox when create succeeds', async () => {
|
||||
const factory = new BuilderSandboxFactory(makeConfig(), undefined);
|
||||
const bw = await factory.create('b-3', makeContext());
|
||||
|
||||
expect(capturedSandboxes).toHaveLength(1);
|
||||
expect(capturedSandboxes[0].destroy).not.toHaveBeenCalled();
|
||||
|
||||
await bw.cleanup();
|
||||
|
||||
expect(capturedSandboxes[0].destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -259,33 +259,44 @@ export class BuilderSandboxFactory {
|
||||
dockerfile,
|
||||
});
|
||||
|
||||
const workspace = new Workspace({
|
||||
sandbox,
|
||||
filesystem: new N8nSandboxFilesystem(sandbox),
|
||||
});
|
||||
|
||||
await workspace.init();
|
||||
|
||||
const root = await getWorkspaceRoot(workspace);
|
||||
if (workspace.filesystem) {
|
||||
await workspace.filesystem.writeFile(`${root}/node-types/index.txt`, catalog);
|
||||
} else {
|
||||
await writeFileViaSandbox(workspace, `${root}/node-types/index.txt`, catalog);
|
||||
}
|
||||
|
||||
await this.linkWorkspaceSdkIfEnabled(workspace, root);
|
||||
|
||||
return {
|
||||
workspace,
|
||||
cleanup: async () => {
|
||||
await cleanupTrackedSandboxProcesses(workspace);
|
||||
try {
|
||||
await sandbox.destroy();
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
},
|
||||
const destroySandbox = async (): Promise<void> => {
|
||||
try {
|
||||
await sandbox.destroy();
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const workspace = new Workspace({
|
||||
sandbox,
|
||||
filesystem: new N8nSandboxFilesystem(sandbox),
|
||||
});
|
||||
|
||||
await workspace.init();
|
||||
|
||||
const root = await getWorkspaceRoot(workspace);
|
||||
if (workspace.filesystem) {
|
||||
await workspace.filesystem.writeFile(`${root}/node-types/index.txt`, catalog);
|
||||
} else {
|
||||
await writeFileViaSandbox(workspace, `${root}/node-types/index.txt`, catalog);
|
||||
}
|
||||
|
||||
await this.linkWorkspaceSdkIfEnabled(workspace, root);
|
||||
|
||||
return {
|
||||
workspace,
|
||||
cleanup: async () => {
|
||||
await cleanupTrackedSandboxProcesses(workspace);
|
||||
await destroySandbox();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If any step after sandbox creation throws (workspace init, catalog
|
||||
// write, SDK link), destroy the remote sandbox so it isn't orphaned.
|
||||
await destroySandbox();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private assertIsDaytona(): Extract<SandboxConfig, { enabled: true; provider: 'daytona' }> {
|
||||
|
||||
@@ -125,6 +125,7 @@ export type { SwitchCaseTarget } from './workflow-builder/control-flow-builders/
|
||||
|
||||
// Split in batches
|
||||
export { splitInBatches } from './workflow-builder/control-flow-builders/split-in-batches';
|
||||
export type { SplitInBatchesTarget } from './types/base';
|
||||
|
||||
// Note: fanOut() removed - use plain arrays for parallel connections
|
||||
// Note: fanIn() removed - use multiple .to(node.input(n)) calls instead
|
||||
|
||||
@@ -96,49 +96,62 @@ export default workflow('id', 'name')
|
||||
</independent_sources>
|
||||
|
||||
<zero_item_safety>
|
||||
Nodes that fetch or filter data may return 0 items, which stops the entire downstream chain.
|
||||
Use \`alwaysOutputData: true\` on data-fetching nodes to ensure the chain continues with an empty item \`{json: {}}\`.
|
||||
When a node returns 0 items, downstream nodes are skipped for that execution. **This is usually the correct behavior** — the scheduler / trigger fires again later, and when there is data, the chain runs normally. Don't paper over an empty result with \`alwaysOutputData: true\` by default.
|
||||
|
||||
**\`alwaysOutputData: true\` forces a synthetic \`{json: {}}\` item downstream.** This is a footgun: downstream nodes will try to read fields that don't exist, HTTP requests will hit \`GET undefined\`, and loops will run once on a fake item. Use it *only* when the empty case has its own dedicated branch that you want to execute.
|
||||
|
||||
**Correct pattern — no \`alwaysOutputData\`:**
|
||||
\`\`\`javascript
|
||||
// Data Table might be empty (fresh table, no matching rows)
|
||||
const getReflections = node({
|
||||
type: 'n8n-nodes-base.dataTable',
|
||||
version: 1.1,
|
||||
// Scheduler that processes pending work
|
||||
workflow('ingest', 'Ingest Worker')
|
||||
.add(scheduleTrigger) // fires every 5 min
|
||||
.to(getPending) // returns 0..N rows; no alwaysOutputData
|
||||
.to(splitInBatches({version: 3, config: {parameters: {batchSize: 1}}})
|
||||
.onEachBatch(fetchUrl.to(embed).to(saveChunk))
|
||||
);
|
||||
// On runs where getPending returns 0 items, the loop simply doesn't execute.
|
||||
// On runs where it returns rows, the loop iterates. No gate, no filter needed.
|
||||
\`\`\`
|
||||
|
||||
**Correct pattern — empty case needs its own branch:**
|
||||
\`\`\`javascript
|
||||
// "No matches found" deserves a notification
|
||||
const search = node({
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
version: 4.4,
|
||||
config: {
|
||||
name: 'Get Reflections',
|
||||
alwaysOutputData: true, // Chain continues even if table is empty
|
||||
parameters: { resource: 'row', operation: 'get', returnAll: true }
|
||||
name: 'Search',
|
||||
alwaysOutputData: true, // empty-case branch below needs to execute
|
||||
parameters: { /* ... */ }
|
||||
}
|
||||
});
|
||||
|
||||
// Downstream Code node handles the empty case
|
||||
const processData = node({
|
||||
type: 'n8n-nodes-base.code',
|
||||
version: 2,
|
||||
const hasResults = ifElse({
|
||||
version: 2.2,
|
||||
config: {
|
||||
name: 'Process Data',
|
||||
name: 'Has Results?',
|
||||
parameters: {
|
||||
mode: 'runOnceForAllItems',
|
||||
jsCode: \\\`
|
||||
const items = $input.all();
|
||||
// items will be [{json: {}}] if upstream had no data
|
||||
const hasData = items.length > 0 && Object.keys(items[0].json).length > 0;
|
||||
// ... handle both cases
|
||||
\\\`.trim()
|
||||
conditions: {
|
||||
options: { caseSensitive: true, typeValidation: 'loose' },
|
||||
conditions: [{ leftValue: '={{ $json.results }}', operator: { type: 'array', operation: 'notEmpty' } }],
|
||||
combinator: 'and'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
workflow('search', 'Search').add(trigger).to(search).to(
|
||||
hasResults.onTrue(processResults).onFalse(notifyNoMatches)
|
||||
);
|
||||
\`\`\`
|
||||
|
||||
**When to use \`alwaysOutputData: true\`:**
|
||||
- Data Table with \`operation: 'get'\` (table may be empty or freshly created)
|
||||
- Any lookup/search/filter node whose result feeds into downstream processing
|
||||
- HTTP Request that may return an empty array
|
||||
**When to use \`alwaysOutputData: true\`:** only when you've paired it with an explicit empty-case branch, AND the downstream branch doesn't blindly read item fields.
|
||||
|
||||
**When NOT to use it:**
|
||||
- Trigger nodes (they always produce output)
|
||||
- Code nodes (handle empty input in your code logic instead)
|
||||
- Nodes at the end of the chain (no downstream to protect)
|
||||
- Scheduled/polling triggers where the "no work" case should silently skip
|
||||
- Before a \`splitInBatches\` loop — loops already no-op on empty input
|
||||
- Before a \`filter\` — the filter already no-ops on empty input
|
||||
- When all you'd do on the empty case is "nothing"
|
||||
|
||||
**Don't gate loops with an \`IF\`.** \`ifElse.onTrue(splitInBatches)\` to check "are there items?" is redundant — the loop already does the right thing with 0 items. Drop the IF.
|
||||
|
||||
</zero_item_safety>
|
||||
|
||||
|
||||
@@ -14,14 +14,21 @@ export const WORKFLOW_RULES = `Follow these rules strictly when generating workf
|
||||
- Example: \`credentials: { slackApi: newCredential('Slack Bot') }\`
|
||||
- The credential type must match what the node expects
|
||||
|
||||
2. **Handle empty outputs with \`alwaysOutputData: true\`**
|
||||
- Nodes that query data (Data Table get, Google Sheets lookup, HTTP Request, etc.) may return 0 items
|
||||
- When a node returns 0 items, all downstream nodes are SKIPPED — the workflow chain breaks silently
|
||||
- Set \`alwaysOutputData: true\` on any node whose output feeds downstream nodes and might return empty results
|
||||
- Common cases: fresh/empty Data Tables, filtered queries, conditional lookups, API searches with no matches
|
||||
- Example: \`config: { ..., alwaysOutputData: true }\`
|
||||
2. **Trust empty item lists — don't synthesize fake items**
|
||||
- When a query returns 0 items, downstream nodes simply don't run for that execution. For scheduled or polling triggers this is the correct "nothing to do this round" signal — the next run will execute normally when data appears.
|
||||
- DO NOT add \`alwaysOutputData: true\` just to "keep the chain alive." Forcing an empty \`{}\` item downstream is what causes \`undefined\` reads, failed HTTP calls to \`GET undefined\`, and Code-node crashes on missing fields.
|
||||
- DO NOT add an IF gate before a loop to check "has items?" — loops (\`splitInBatches\`, per-item nodes, \`filter\`) already no-op on empty input. The gate is redundant and adds a failure surface.
|
||||
- \`alwaysOutputData: true\` is only correct when you specifically need a downstream branch to run on the "empty" case — e.g. a dedicated "no matches found" notification path. In that case, pair it with an \`IF\` that explicitly checks for the empty case and routes accordingly. Never use it as a default.
|
||||
- To drop invalid items mid-pipeline, use a \`filter\` node. A \`filter\` that rejects everything emits 0 items and the chain correctly stops — no \`IF\` + \`splitInBatches\` composition needed.
|
||||
|
||||
3. **Use \`executeOnce: true\` for single-execution nodes**
|
||||
- When a node receives N items but should only execute once (not N times), set \`executeOnce: true\`
|
||||
- Common cases: sending a summary notification, generating a report, calling an API that doesn't need per-item execution
|
||||
- Example: \`config: { ..., executeOnce: true }\``;
|
||||
- Example: \`config: { ..., executeOnce: true }\`
|
||||
|
||||
4. **Pick the right control-flow primitive**
|
||||
- **Per-item loop with side effects (fetch, embed, write)** → \`splitInBatches\` with \`batchSize: 1\` feeding the per-item work, loop back via \`nextBatch\`. No \`IF\` gate before it.
|
||||
- **Drop items that don't match a predicate** → \`filter\`. It emits 0 items when nothing matches, and the chain stops cleanly.
|
||||
- **Two mutually exclusive paths that both do real work** → \`IF\` (\`onTrue\` / \`onFalse\`).
|
||||
- **Many mutually exclusive paths keyed off a value** → \`switch\` (\`onCase\`).
|
||||
- Nested control flow is supported: \`ifNode.onTrue(loopBuilder)\`, \`switchNode.onCase(0, loopBuilder)\`, and \`splitInBatches(sib).onEachBatch(ifElseBuilder)\` all compile and wire correctly. Use them when the semantics genuinely call for it, not as a workaround for empty-list handling.`;
|
||||
|
||||
@@ -791,7 +791,8 @@ export type IfElseTarget =
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
>
|
||||
| IfElseBuilder<unknown>
|
||||
| SwitchCaseBuilder<unknown>;
|
||||
| SwitchCaseBuilder<unknown>
|
||||
| SplitInBatchesBuilder<unknown>;
|
||||
|
||||
/**
|
||||
* Target type for Switch case branches - can be a node, chain, null, plain array (fan-out), or nested builder
|
||||
@@ -805,7 +806,24 @@ export type SwitchCaseTarget =
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
>
|
||||
| IfElseBuilder<unknown>
|
||||
| SwitchCaseBuilder<unknown>;
|
||||
| SwitchCaseBuilder<unknown>
|
||||
| SplitInBatchesBuilder<unknown>;
|
||||
|
||||
/**
|
||||
* Target type for SplitInBatches `onEachBatch` / `onDone` branches - can be a node,
|
||||
* chain, null, plain array (fan-out), or nested control-flow builder.
|
||||
*/
|
||||
export type SplitInBatchesTarget =
|
||||
| null
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
| Array<
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
>
|
||||
| IfElseBuilder<unknown>
|
||||
| SwitchCaseBuilder<unknown>
|
||||
| SplitInBatchesBuilder<unknown>;
|
||||
|
||||
/**
|
||||
* Fluent builder for IF nodes with onTrue/onFalse methods.
|
||||
@@ -848,10 +866,14 @@ export interface IfElseBuilder<TOutput = unknown> {
|
||||
onFalse(target: IfElseTarget): IfElseBuilder<TOutput>;
|
||||
|
||||
/**
|
||||
* Set the target for the error branch (output 2).
|
||||
* Only applicable when the IF node has onError: 'continueErrorOutput'.
|
||||
* Set the target for the IF node's own error output (output 2).
|
||||
*
|
||||
* @param target - The node or chain to execute on error
|
||||
* This wires the IF node's error branch — it is NOT a generic per-node
|
||||
* "on-error" output. Only applicable when the IF node's config sets
|
||||
* `onError: 'continueErrorOutput'`. For wiring a regular node's error
|
||||
* output, use `node.onError(handler)` on the source node instead.
|
||||
*
|
||||
* @param target - The node or chain to execute when the IF condition errors
|
||||
*/
|
||||
onError(target: IfElseTarget): IfElseBuilder<TOutput>;
|
||||
|
||||
@@ -938,16 +960,7 @@ export interface SplitInBatchesBuilder<TOutput = unknown> {
|
||||
* .onEachBatch(processNode.to(sibNode))
|
||||
* .onDone(finalizeNode)
|
||||
*/
|
||||
onEachBatch(
|
||||
target:
|
||||
| null
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
| Array<
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
>,
|
||||
): SplitInBatchesBuilder<TOutput>;
|
||||
onEachBatch(target: SplitInBatchesTarget): SplitInBatchesBuilder<TOutput>;
|
||||
|
||||
/**
|
||||
* Fluent API: Set the "done" branch target (output 0).
|
||||
@@ -959,16 +972,7 @@ export interface SplitInBatchesBuilder<TOutput = unknown> {
|
||||
* .onDone(finalizeNode)
|
||||
* .onEachBatch(processNode.to(sibNode))
|
||||
*/
|
||||
onDone(
|
||||
target:
|
||||
| null
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
| Array<
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
>,
|
||||
): SplitInBatchesBuilder<TOutput>;
|
||||
onDone(target: SplitInBatchesTarget): SplitInBatchesBuilder<TOutput>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -768,4 +768,121 @@ describe('New SDK API', () => {
|
||||
expect(json.connections['Handle Error'].main[0]![0].node).toBe('Notify');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Nested control-flow targets', () => {
|
||||
it('allows ifElse.onTrue(splitInBatchesBuilder)', () => {
|
||||
const t = createTrigger('Start');
|
||||
const ifNode = createIfNode('HasItems?');
|
||||
const sibNode = createSplitInBatchesNode('Loop');
|
||||
const processNode = createNode('Process');
|
||||
const doneNode = createNode('Done');
|
||||
|
||||
const loop = splitInBatches(sibNode).onEachBatch(processNode).onDone(doneNode);
|
||||
|
||||
const wf = workflow('test', 'Test').add(t).to(ifNode.onTrue!(loop).onFalse(null));
|
||||
|
||||
const json = wf.toJSON();
|
||||
|
||||
// IF true output wires to the SIB node (head of the nested builder)
|
||||
expect(json.connections['HasItems?'].main[0]![0].node).toBe('Loop');
|
||||
// SIB branches are wired by the SIB handler
|
||||
expect(json.connections['Loop'].main[0]![0].node).toBe('Done');
|
||||
expect(json.connections['Loop'].main[1]![0].node).toBe('Process');
|
||||
});
|
||||
|
||||
it('allows ifElse.onFalse(splitInBatchesBuilder)', () => {
|
||||
const t = createTrigger('Start');
|
||||
const ifNode = createIfNode('Flag?');
|
||||
const sibNode = createSplitInBatchesNode('Loop');
|
||||
const processNode = createNode('Process');
|
||||
const successNode = createNode('Success');
|
||||
|
||||
const loop = splitInBatches(sibNode).onEachBatch(processNode);
|
||||
|
||||
const wf = workflow('test', 'Test').add(t).to(ifNode.onTrue!(successNode).onFalse(loop));
|
||||
|
||||
const json = wf.toJSON();
|
||||
|
||||
expect(json.connections['Flag?'].main[0]![0].node).toBe('Success');
|
||||
expect(json.connections['Flag?'].main[1]![0].node).toBe('Loop');
|
||||
expect(json.connections['Loop'].main[1]![0].node).toBe('Process');
|
||||
});
|
||||
|
||||
it('allows ifElse.onError(splitInBatchesBuilder)', () => {
|
||||
const t = createTrigger('Start');
|
||||
const ifNode = node({
|
||||
type: 'n8n-nodes-base.if',
|
||||
version: 2.2,
|
||||
config: {
|
||||
name: 'Gate',
|
||||
onError: 'continueErrorOutput',
|
||||
parameters: {
|
||||
conditions: {
|
||||
conditions: [
|
||||
{
|
||||
leftValue: '={{ $json.value }}',
|
||||
operator: { type: 'boolean', operation: 'true' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as NodeInstance<'n8n-nodes-base.if', string, unknown>;
|
||||
const sibNode = createSplitInBatchesNode('ErrorLoop');
|
||||
const recover = createNode('Recover');
|
||||
const ok = createNode('OK');
|
||||
|
||||
const loop = splitInBatches(sibNode).onEachBatch(recover);
|
||||
|
||||
const wf = workflow('test', 'Test').add(t).to(ifNode.onTrue!(ok).onFalse(null).onError(loop));
|
||||
|
||||
const json = wf.toJSON();
|
||||
|
||||
// IF with onError='continueErrorOutput' emits three main outputs: true/false/error.
|
||||
// The error branch lands on output 2 and points at the SIB head.
|
||||
expect(json.connections['Gate'].main[2]![0].node).toBe('ErrorLoop');
|
||||
});
|
||||
|
||||
it('allows switch.onCase(n, splitInBatchesBuilder)', () => {
|
||||
const t = createTrigger('Start');
|
||||
const switchNode = createSwitchNode('Route');
|
||||
const sibNode = createSplitInBatchesNode('BatchA');
|
||||
const processA = createNode('ProcessA');
|
||||
const simple = createNode('Simple');
|
||||
|
||||
const loop = splitInBatches(sibNode).onEachBatch(processA);
|
||||
|
||||
const wf = workflow('test', 'Test').add(t).to(switchNode.onCase!(0, loop).onCase(1, simple));
|
||||
|
||||
const json = wf.toJSON();
|
||||
|
||||
expect(json.connections['Route'].main[0]![0].node).toBe('BatchA');
|
||||
expect(json.connections['Route'].main[1]![0].node).toBe('Simple');
|
||||
});
|
||||
|
||||
it('allows splitInBatches.onEachBatch(ifElseBuilder)', () => {
|
||||
const t = createTrigger('Start');
|
||||
const sibNode = createSplitInBatchesNode('Loop');
|
||||
const ifNode = createIfNode('PerItem');
|
||||
const hit = createNode('Hit');
|
||||
const miss = createNode('Miss');
|
||||
const finalize = createNode('Finalize');
|
||||
|
||||
const loop = splitInBatches(sibNode)
|
||||
.onEachBatch(ifNode.onTrue!(hit).onFalse(miss))
|
||||
.onDone(finalize);
|
||||
|
||||
const wf = workflow('test', 'Test').add(t).to(loop);
|
||||
|
||||
const json = wf.toJSON();
|
||||
|
||||
// SIB each output (index 1) -> IF head
|
||||
expect(json.connections['Loop'].main[1]![0].node).toBe('PerItem');
|
||||
// IF branches materialised
|
||||
expect(json.connections['PerItem'].main[0]![0].node).toBe('Hit');
|
||||
expect(json.connections['PerItem'].main[1]![0].node).toBe('Miss');
|
||||
// Done output wired to finalize
|
||||
expect(json.connections['Loop'].main[0]![0].node).toBe('Finalize');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1 @@
|
||||
import type { NodeInstance, NodeChain, IfElseBuilder, SwitchCaseBuilder } from '../../types/base';
|
||||
|
||||
/**
|
||||
* A branch target - can be a node, node chain, null, plain array (fan-out), or nested builder
|
||||
*/
|
||||
export type IfElseTarget =
|
||||
| null
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
| Array<
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
>
|
||||
| IfElseBuilder<unknown>
|
||||
| SwitchCaseBuilder<unknown>;
|
||||
export type { IfElseTarget } from '../../types/base';
|
||||
|
||||
+50
-5
@@ -90,6 +90,7 @@ export type NodeBatch =
|
||||
* - NodeInstance: single target
|
||||
* - NodeChain: a chain of nodes
|
||||
* - Plain array: multiple parallel targets (fan-out)
|
||||
* - Nested control-flow builder (IfElse / SwitchCase / SplitInBatches)
|
||||
*/
|
||||
export type BranchTarget =
|
||||
| null
|
||||
@@ -98,7 +99,10 @@ export type BranchTarget =
|
||||
| Array<
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
>;
|
||||
>
|
||||
| IfElseBuilder<unknown>
|
||||
| SwitchCaseBuilder<unknown>
|
||||
| SplitInBatchesBuilder<unknown>;
|
||||
|
||||
/**
|
||||
* Named object syntax for splitInBatches branches.
|
||||
@@ -396,7 +400,7 @@ function extractNodesFromTarget(
|
||||
}
|
||||
// Handle IfElseBuilder (fluent API)
|
||||
if (isIfElseBuilder(target)) {
|
||||
const builder = target as IfElseBuilder<unknown>;
|
||||
const builder = target;
|
||||
const nodes: Array<NodeInstance<string, string, unknown>> = [builder.ifNode];
|
||||
nodes.push(...extractNodesFromTarget(builder.trueBranch as BranchTarget));
|
||||
nodes.push(...extractNodesFromTarget(builder.falseBranch as BranchTarget));
|
||||
@@ -404,15 +408,52 @@ function extractNodesFromTarget(
|
||||
}
|
||||
// Handle SwitchCaseBuilder (fluent API)
|
||||
if (isSwitchCaseBuilder(target)) {
|
||||
const builder = target as SwitchCaseBuilder<unknown>;
|
||||
const builder = target;
|
||||
const nodes: Array<NodeInstance<string, string, unknown>> = [builder.switchNode];
|
||||
for (const caseTarget of builder.caseMapping.values()) {
|
||||
nodes.push(...extractNodesFromTarget(caseTarget as BranchTarget));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
// Handle nested SplitInBatchesBuilder (duck-type so it covers all three impl classes)
|
||||
if (isSplitInBatchesBuilderShape(target)) {
|
||||
const nodes: Array<NodeInstance<string, string, unknown>> = [target.sibNode];
|
||||
for (const doneBatch of target._doneBatches) {
|
||||
nodes.push(...extractNodesFromTarget(doneBatch as BranchTarget));
|
||||
}
|
||||
for (const eachBatch of target._eachBatches) {
|
||||
nodes.push(...extractNodesFromTarget(eachBatch as BranchTarget));
|
||||
}
|
||||
if (target._doneTarget !== undefined) {
|
||||
nodes.push(...extractNodesFromTarget(target._doneTarget));
|
||||
}
|
||||
if (target._eachTarget !== undefined) {
|
||||
nodes.push(...extractNodesFromTarget(target._eachTarget));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
// It's a single NodeInstance
|
||||
return [target];
|
||||
return [target as NodeInstance<string, string, unknown>];
|
||||
}
|
||||
|
||||
/**
|
||||
* Duck-type check for any SplitInBatchesBuilder implementation (Impl, WithExistingNode,
|
||||
* NamedSyntax). Covers all three classes because they all expose the same shape.
|
||||
*/
|
||||
function isSplitInBatchesBuilderShape(value: unknown): value is {
|
||||
sibNode: NodeInstance<'n8n-nodes-base.splitInBatches', string, unknown>;
|
||||
_doneBatches: NodeBatch[];
|
||||
_eachBatches: NodeBatch[];
|
||||
_doneTarget?: BranchTarget;
|
||||
_eachTarget?: BranchTarget;
|
||||
} {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'sibNode' in value &&
|
||||
'_doneBatches' in value &&
|
||||
'_eachBatches' in value
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,8 +477,12 @@ function getFirstNodes(target: BranchTarget): Array<NodeInstance<string, string,
|
||||
if (isSwitchCaseBuilder(target)) {
|
||||
return [target.switchNode];
|
||||
}
|
||||
// Handle nested SplitInBatchesBuilder - head node is the SIB node
|
||||
if (isSplitInBatchesBuilderShape(target)) {
|
||||
return [target.sibNode];
|
||||
}
|
||||
// It's a single NodeInstance
|
||||
return [target];
|
||||
return [target as NodeInstance<string, string, unknown>];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-13
@@ -1,13 +1 @@
|
||||
import type { NodeInstance, NodeChain } from '../../types/base';
|
||||
|
||||
/**
|
||||
* A case target - can be a node, node chain, null, or plain array (fan-out)
|
||||
*/
|
||||
export type SwitchCaseTarget =
|
||||
| null
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
| Array<
|
||||
| NodeInstance<string, string, unknown>
|
||||
| NodeChain<NodeInstance<string, string, unknown>, NodeInstance<string, string, unknown>>
|
||||
>;
|
||||
export type { SwitchCaseTarget } from '../../types/base';
|
||||
|
||||
@@ -581,6 +581,27 @@ function extractNodesFromTarget(target: unknown): Array<NodeInstance<string, str
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Handle SplitInBatchesBuilder (fluent API) - the sibNode plus any recorded
|
||||
// done/each branch targets. Recurses so nested builders inside either branch
|
||||
// are collected too.
|
||||
if (isSplitInBatchesBuilder(target)) {
|
||||
const builder = extractSplitInBatchesBuilder(target);
|
||||
const nodes: Array<NodeInstance<string, string, unknown>> = [builder.sibNode];
|
||||
for (const doneTarget of builder._doneBatches) {
|
||||
nodes.push(...extractNodesFromTarget(doneTarget));
|
||||
}
|
||||
for (const eachTarget of builder._eachBatches) {
|
||||
nodes.push(...extractNodesFromTarget(eachTarget));
|
||||
}
|
||||
if (builder._doneTarget !== undefined) {
|
||||
nodes.push(...extractNodesFromTarget(builder._doneTarget));
|
||||
}
|
||||
if (builder._eachTarget !== undefined) {
|
||||
nodes.push(...extractNodesFromTarget(builder._eachTarget));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Check if it's a node-like object with type, version, config
|
||||
if (
|
||||
target !== null &&
|
||||
|
||||
+124
-1
@@ -15,6 +15,7 @@ import {
|
||||
extractExecutionResult,
|
||||
extractExecutionDebugInfo,
|
||||
extractNodeOutput,
|
||||
resolveDataTableByIdOrName,
|
||||
truncateNodeOutput,
|
||||
truncateResultData,
|
||||
} from '../instance-ai.adapter.service';
|
||||
@@ -826,7 +827,7 @@ function createDataTableAdapterForTests(overrides?: {
|
||||
};
|
||||
|
||||
const mockDataTableRepository = {
|
||||
findOneByOrFail: jest
|
||||
findOneBy: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ id: 'dt-1', name: 'Orders', projectId: 'team-project-id' }),
|
||||
};
|
||||
@@ -1546,3 +1547,125 @@ describe('createExecutionAdapter', () => {
|
||||
expect(query).not.toHaveProperty('accessibleWorkflowIds');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// resolveDataTableByIdOrName
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('resolveDataTableByIdOrName', () => {
|
||||
type TableRecord = { id: string; name: string; projectId: string };
|
||||
|
||||
function makeRepo(tables: TableRecord[]) {
|
||||
return {
|
||||
findOneBy: jest.fn(async (where: { id: string }) => {
|
||||
return tables.find((t) => t.id === where.id) ?? null;
|
||||
}),
|
||||
findBy: jest.fn(async (where: { name: string; projectId?: string }) => {
|
||||
return tables.filter(
|
||||
(t) => t.name === where.name && (!where.projectId || t.projectId === where.projectId),
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function makeLogger() {
|
||||
return { warn: jest.fn() };
|
||||
}
|
||||
|
||||
const table = { id: 'dt_uuid_123', name: 'kb_sources', projectId: 'proj_1' };
|
||||
|
||||
it('returns hit on an id match without logging a warning', async () => {
|
||||
const repo = makeRepo([table]);
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await resolveDataTableByIdOrName(repo, logger, 'dt_uuid_123');
|
||||
|
||||
expect(result).toEqual({ kind: 'hit', table });
|
||||
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'dt_uuid_123' });
|
||||
expect(repo.findBy).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('falls back to name lookup when the id lookup misses, and warns', async () => {
|
||||
const repo = makeRepo([table]);
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await resolveDataTableByIdOrName(repo, logger, 'kb_sources');
|
||||
|
||||
expect(result).toEqual({ kind: 'hit', table });
|
||||
expect(repo.findOneBy).toHaveBeenCalledWith({ id: 'kb_sources' });
|
||||
expect(repo.findBy).toHaveBeenCalledWith({ name: 'kb_sources' });
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logger.warn.mock.calls[0][0]).toMatch(/called with table name instead of id/);
|
||||
expect(logger.warn.mock.calls[0][1]).toEqual({
|
||||
passedValue: 'kb_sources',
|
||||
resolvedId: 'dt_uuid_123',
|
||||
projectId: 'proj_1',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns miss when neither id nor name matches', async () => {
|
||||
const repo = makeRepo([table]);
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await resolveDataTableByIdOrName(repo, logger, 'does_not_exist');
|
||||
|
||||
expect(result).toEqual({ kind: 'miss' });
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('filters id hits that fail the access filter', async () => {
|
||||
const repo = makeRepo([table]);
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await resolveDataTableByIdOrName(repo, logger, 'dt_uuid_123', {
|
||||
accessFilter: async () => false,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ kind: 'miss' });
|
||||
});
|
||||
|
||||
it('narrows the name lookup when projectIdFilter is provided', async () => {
|
||||
const repo = makeRepo([table, { id: 'dt_uuid_456', name: 'kb_sources', projectId: 'proj_2' }]);
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await resolveDataTableByIdOrName(repo, logger, 'kb_sources', {
|
||||
projectIdFilter: 'proj_2',
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('hit');
|
||||
expect(repo.findBy).toHaveBeenCalledWith({ name: 'kb_sources', projectId: 'proj_2' });
|
||||
if (result.kind === 'hit') expect(result.table.id).toBe('dt_uuid_456');
|
||||
});
|
||||
|
||||
it('returns ambiguous when multiple accessible candidates share a name', async () => {
|
||||
const twin = { id: 'dt_uuid_456', name: 'kb_sources', projectId: 'proj_2' };
|
||||
const repo = makeRepo([table, twin]);
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await resolveDataTableByIdOrName(repo, logger, 'kb_sources', {
|
||||
accessFilter: async () => true,
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('ambiguous');
|
||||
if (result.kind === 'ambiguous') {
|
||||
expect(result.candidates).toHaveLength(2);
|
||||
expect(result.candidates.map((c) => c.projectId).sort()).toEqual(['proj_1', 'proj_2']);
|
||||
}
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('picks the single accessible candidate when ambiguity is resolved by access filter', async () => {
|
||||
const twin = { id: 'dt_uuid_456', name: 'kb_sources', projectId: 'proj_2' };
|
||||
const repo = makeRepo([table, twin]);
|
||||
const logger = makeLogger();
|
||||
|
||||
const result = await resolveDataTableByIdOrName(repo, logger, 'kb_sources', {
|
||||
accessFilter: async (id) => id === 'dt_uuid_123',
|
||||
});
|
||||
|
||||
expect(result.kind).toBe('hit');
|
||||
if (result.kind === 'hit') expect(result.table.id).toBe('dt_uuid_123');
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import type { z as zType } from 'zod';
|
||||
|
||||
// Manual mocks — must be declared before any imports that touch the mocked modules.
|
||||
jest.mock('@n8n/instance-ai', () => {
|
||||
const { z } = jest.requireActual<{ z: typeof zType }>('zod');
|
||||
return {
|
||||
McpClientManager: class {
|
||||
disconnect = jest.fn();
|
||||
},
|
||||
createDomainAccessTracker: jest.fn(),
|
||||
BuilderSandboxFactory: class {},
|
||||
SnapshotManager: class {},
|
||||
createSandbox: jest.fn(),
|
||||
createWorkspace: jest.fn(),
|
||||
workflowBuildOutcomeSchema: z.object({}),
|
||||
handleBuildOutcome: jest.fn(),
|
||||
handleVerificationVerdict: jest.fn(),
|
||||
createInstanceAgent: jest.fn(),
|
||||
createAllTools: jest.fn(),
|
||||
createMemory: jest.fn(),
|
||||
mapMastraChunkToEvent: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('@mastra/core/agent', () => ({}));
|
||||
jest.mock('@mastra/core/storage', () => ({
|
||||
MemoryStorage: class {},
|
||||
MastraCompositeStore: class {},
|
||||
WorkflowsStorage: class {},
|
||||
}));
|
||||
jest.mock('@mastra/memory', () => ({
|
||||
Memory: class {},
|
||||
}));
|
||||
jest.mock('@mastra/core/workflows', () => ({}));
|
||||
|
||||
import type { User } from '@n8n/db';
|
||||
|
||||
import { InstanceAiService } from '../instance-ai.service';
|
||||
|
||||
type ServiceInternals = {
|
||||
pendingCheckpointReentries: Map<string, Set<string>>;
|
||||
queuePendingCheckpointReentry: (threadId: string, checkpointTaskId: string) => void;
|
||||
drainPendingCheckpointReentries: (user: User, threadId: string) => Promise<void>;
|
||||
reenterCheckpointById: jest.Mock<Promise<boolean>, [User, string, string, string?]>;
|
||||
backgroundTasks: {
|
||||
getRunningTasksByParentCheckpoint: jest.Mock;
|
||||
};
|
||||
runState: {
|
||||
getActiveRunId: jest.Mock;
|
||||
hasSuspendedRun: jest.Mock;
|
||||
};
|
||||
logger: { debug: jest.Mock; warn: jest.Mock; error: jest.Mock };
|
||||
};
|
||||
|
||||
function createCheckpointService(): ServiceInternals {
|
||||
// Bypass the constructor — we only exercise the three pending-reentry helpers
|
||||
// and their direct dependencies. Everything else (scheduler, event bus, etc.)
|
||||
// is out of scope for this unit.
|
||||
const service = Object.create(InstanceAiService.prototype) as unknown as ServiceInternals;
|
||||
|
||||
service.pendingCheckpointReentries = new Map();
|
||||
service.reenterCheckpointById = jest.fn(
|
||||
async (_user: User, _threadId: string, _checkpointTaskId: string, _mgid?: string) => true,
|
||||
);
|
||||
service.backgroundTasks = {
|
||||
getRunningTasksByParentCheckpoint: jest.fn(() => []),
|
||||
};
|
||||
service.runState = {
|
||||
getActiveRunId: jest.fn(() => undefined),
|
||||
hasSuspendedRun: jest.fn(() => false),
|
||||
};
|
||||
service.logger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
const fakeUser = { id: 'user-1' } as User;
|
||||
|
||||
describe('InstanceAiService — pending checkpoint re-entry', () => {
|
||||
describe('queuePendingCheckpointReentry', () => {
|
||||
it('records a marker keyed by threadId + checkpointTaskId', () => {
|
||||
const service = createCheckpointService();
|
||||
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-1');
|
||||
|
||||
expect(service.pendingCheckpointReentries.get('thread-a')).toEqual(new Set(['cp-1']));
|
||||
});
|
||||
|
||||
it('deduplicates markers for the same (thread, checkpoint) pair', () => {
|
||||
const service = createCheckpointService();
|
||||
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-1');
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-1');
|
||||
|
||||
expect(service.pendingCheckpointReentries.get('thread-a')?.size).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps markers for different threads separate', () => {
|
||||
const service = createCheckpointService();
|
||||
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-1');
|
||||
service.queuePendingCheckpointReentry('thread-b', 'cp-1');
|
||||
|
||||
expect(service.pendingCheckpointReentries.get('thread-a')).toEqual(new Set(['cp-1']));
|
||||
expect(service.pendingCheckpointReentries.get('thread-b')).toEqual(new Set(['cp-1']));
|
||||
});
|
||||
});
|
||||
|
||||
describe('drainPendingCheckpointReentries', () => {
|
||||
it('fires re-entry for each queued marker when the thread is idle', async () => {
|
||||
const service = createCheckpointService();
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-1');
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-2');
|
||||
|
||||
await service.drainPendingCheckpointReentries(fakeUser, 'thread-a');
|
||||
|
||||
expect(service.reenterCheckpointById).toHaveBeenCalledTimes(2);
|
||||
expect(service.reenterCheckpointById).toHaveBeenCalledWith(fakeUser, 'thread-a', 'cp-1');
|
||||
expect(service.reenterCheckpointById).toHaveBeenCalledWith(fakeUser, 'thread-a', 'cp-2');
|
||||
expect(service.pendingCheckpointReentries.get('thread-a')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('stops draining if a new run starts mid-drain', async () => {
|
||||
const service = createCheckpointService();
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-1');
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-2');
|
||||
|
||||
// After the first re-entry fires, simulate a new active run.
|
||||
let calls = 0;
|
||||
service.reenterCheckpointById.mockImplementation(async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
service.runState.getActiveRunId.mockReturnValue('run-new');
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
await service.drainPendingCheckpointReentries(fakeUser, 'thread-a');
|
||||
|
||||
// First marker drained; second should remain queued for the next run's cleanup.
|
||||
expect(service.reenterCheckpointById).toHaveBeenCalledTimes(1);
|
||||
expect(service.pendingCheckpointReentries.get('thread-a')).toEqual(new Set(['cp-2']));
|
||||
});
|
||||
|
||||
it('skips a marker whose parent-tagged siblings are still running', async () => {
|
||||
const service = createCheckpointService();
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-1');
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-2');
|
||||
|
||||
// cp-1 has a sibling still in flight, cp-2 is clear.
|
||||
service.backgroundTasks.getRunningTasksByParentCheckpoint.mockImplementation(
|
||||
(_threadId: string, cp: string) => (cp === 'cp-1' ? [{ taskId: 'sibling-running' }] : []),
|
||||
);
|
||||
|
||||
await service.drainPendingCheckpointReentries(fakeUser, 'thread-a');
|
||||
|
||||
expect(service.reenterCheckpointById).toHaveBeenCalledTimes(1);
|
||||
expect(service.reenterCheckpointById).toHaveBeenCalledWith(fakeUser, 'thread-a', 'cp-2');
|
||||
// cp-1 stays queued — the sibling's own settlement will drive the next drain.
|
||||
expect(service.pendingCheckpointReentries.get('thread-a')).toEqual(new Set(['cp-1']));
|
||||
});
|
||||
|
||||
it('returns early when a suspended run is present', async () => {
|
||||
const service = createCheckpointService();
|
||||
service.queuePendingCheckpointReentry('thread-a', 'cp-1');
|
||||
service.runState.hasSuspendedRun.mockReturnValue(true);
|
||||
|
||||
await service.drainPendingCheckpointReentries(fakeUser, 'thread-a');
|
||||
|
||||
expect(service.reenterCheckpointById).not.toHaveBeenCalled();
|
||||
expect(service.pendingCheckpointReentries.get('thread-a')).toEqual(new Set(['cp-1']));
|
||||
});
|
||||
|
||||
it('is a no-op when no markers are queued', async () => {
|
||||
const service = createCheckpointService();
|
||||
|
||||
await service.drainPendingCheckpointReentries(fakeUser, 'thread-nonexistent');
|
||||
|
||||
expect(service.reenterCheckpointById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1177,24 +1177,65 @@ export class InstanceAiAdapterService {
|
||||
|
||||
const { resolveProjectId } = this.createProjectScopeHelpers(user);
|
||||
|
||||
// Check scope for a data table and return its projectId for downstream service calls
|
||||
const resolveProjectIdForTable = async (scopes: Scope[], dataTableId: string) => {
|
||||
const allowed = await userHasScopes(user, scopes, false, { dataTableId });
|
||||
if (!allowed) {
|
||||
const logger = this.logger;
|
||||
|
||||
/**
|
||||
* Resolve a data-table identifier (UUID or name) to a concrete row the
|
||||
* caller can access. Returns the resolved `id`, `name`, and `projectId`.
|
||||
* Throws on not-found, ambiguous-name (when multiple accessible projects
|
||||
* share the name and no `projectId` disambiguator was given), or
|
||||
* UUID+projectId mismatch (when both are provided but the UUID's actual
|
||||
* project differs from the one passed).
|
||||
*/
|
||||
const resolveAccessibleTable = async (
|
||||
scopes: Scope[],
|
||||
dataTableId: string,
|
||||
disambiguator?: { projectId?: string },
|
||||
): Promise<DataTableRecord> => {
|
||||
const projectIdFilter = disambiguator?.projectId;
|
||||
const result = await resolveDataTableByIdOrName(dataTableRepository, logger, dataTableId, {
|
||||
projectIdFilter,
|
||||
accessFilter: async (id) => await userHasScopes(user, scopes, false, { dataTableId: id }),
|
||||
});
|
||||
if (result.kind === 'miss') {
|
||||
throw new Error(`Data table "${dataTableId}" not found`);
|
||||
}
|
||||
const table = await dataTableRepository.findOneByOrFail({ id: dataTableId });
|
||||
return table.projectId;
|
||||
if (result.kind === 'ambiguous') {
|
||||
const projectIds = result.candidates.map((c) => c.projectId).join(', ');
|
||||
throw new Error(
|
||||
`Data table name "${dataTableId}" is ambiguous across accessible projects ` +
|
||||
`(${projectIds}); pass the UUID or include a \`projectId\` to disambiguate.`,
|
||||
);
|
||||
}
|
||||
// UUID + projectId mismatch: the id hit resolved, but the caller's
|
||||
// disambiguator points at a different project. Never silently drop
|
||||
// the projectId — return mismatch so the caller fixes the call.
|
||||
if (projectIdFilter && result.table.projectId !== projectIdFilter) {
|
||||
throw new Error(
|
||||
`Data table "${dataTableId}" does not belong to project "${projectIdFilter}".`,
|
||||
);
|
||||
}
|
||||
return result.table;
|
||||
};
|
||||
|
||||
// Like resolveProjectIdForTable but also returns the table name for artifact display
|
||||
const resolveTableMeta = async (scopes: Scope[], dataTableId: string) => {
|
||||
const allowed = await userHasScopes(user, scopes, false, { dataTableId });
|
||||
if (!allowed) {
|
||||
throw new Error(`Data table "${dataTableId}" not found`);
|
||||
}
|
||||
const table = await dataTableRepository.findOneByOrFail({ id: dataTableId });
|
||||
return { projectId: table.projectId, tableName: table.name };
|
||||
// Check scope and return projectId + resolved UUID for downstream service calls
|
||||
const resolveProjectIdForTable = async (
|
||||
scopes: Scope[],
|
||||
dataTableId: string,
|
||||
disambiguator?: { projectId?: string },
|
||||
) => {
|
||||
const table = await resolveAccessibleTable(scopes, dataTableId, disambiguator);
|
||||
return { projectId: table.projectId, resolvedId: table.id };
|
||||
};
|
||||
|
||||
// Like resolveProjectIdForTable but also returns the table name
|
||||
const resolveTableMeta = async (
|
||||
scopes: Scope[],
|
||||
dataTableId: string,
|
||||
disambiguator?: { projectId?: string },
|
||||
) => {
|
||||
const table = await resolveAccessibleTable(scopes, dataTableId, disambiguator);
|
||||
return { projectId: table.projectId, tableName: table.name, resolvedId: table.id };
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1231,15 +1272,23 @@ export class InstanceAiAdapterService {
|
||||
};
|
||||
},
|
||||
|
||||
async delete(dataTableId) {
|
||||
async delete(dataTableId, options) {
|
||||
assertNotReadOnly();
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:delete'], dataTableId);
|
||||
await dataTableService.deleteDataTable(dataTableId, projectId);
|
||||
const { projectId, resolvedId } = await resolveProjectIdForTable(
|
||||
['dataTable:delete'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
await dataTableService.deleteDataTable(resolvedId, projectId);
|
||||
},
|
||||
|
||||
async getSchema(dataTableId) {
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:read'], dataTableId);
|
||||
const columns = await dataTableService.getColumns(dataTableId, projectId);
|
||||
async getSchema(dataTableId, options) {
|
||||
const { projectId, resolvedId } = await resolveProjectIdForTable(
|
||||
['dataTable:read'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
const columns = await dataTableService.getColumns(resolvedId, projectId);
|
||||
return columns.map(
|
||||
(c, index): DataTableColumnInfo => ({
|
||||
id: c.id,
|
||||
@@ -1250,10 +1299,14 @@ export class InstanceAiAdapterService {
|
||||
);
|
||||
},
|
||||
|
||||
async addColumn(dataTableId, column) {
|
||||
async addColumn(dataTableId, column, options) {
|
||||
assertNotReadOnly();
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:update'], dataTableId);
|
||||
const result = await dataTableService.addColumn(dataTableId, projectId, column);
|
||||
const { projectId, resolvedId } = await resolveProjectIdForTable(
|
||||
['dataTable:update'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
const result = await dataTableService.addColumn(resolvedId, projectId, column);
|
||||
return {
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
@@ -1262,84 +1315,99 @@ export class InstanceAiAdapterService {
|
||||
};
|
||||
},
|
||||
|
||||
async deleteColumn(dataTableId, columnId) {
|
||||
async deleteColumn(dataTableId, columnId, options) {
|
||||
assertNotReadOnly();
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:update'], dataTableId);
|
||||
await dataTableService.deleteColumn(dataTableId, projectId, columnId);
|
||||
const { projectId, resolvedId } = await resolveProjectIdForTable(
|
||||
['dataTable:update'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
await dataTableService.deleteColumn(resolvedId, projectId, columnId);
|
||||
},
|
||||
|
||||
async renameColumn(dataTableId, columnId, newName) {
|
||||
async renameColumn(dataTableId, columnId, newName, options) {
|
||||
assertNotReadOnly();
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:update'], dataTableId);
|
||||
await dataTableService.renameColumn(dataTableId, projectId, columnId, {
|
||||
const { projectId, resolvedId } = await resolveProjectIdForTable(
|
||||
['dataTable:update'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
await dataTableService.renameColumn(resolvedId, projectId, columnId, {
|
||||
name: newName,
|
||||
});
|
||||
},
|
||||
|
||||
async queryRows(dataTableId, options) {
|
||||
const projectId = await resolveProjectIdForTable(['dataTable:readRow'], dataTableId);
|
||||
return await dataTableService.getManyRowsAndCount(dataTableId, projectId, {
|
||||
const { projectId, resolvedId } = await resolveProjectIdForTable(
|
||||
['dataTable:readRow'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
return await dataTableService.getManyRowsAndCount(resolvedId, projectId, {
|
||||
take: options?.limit ?? 50,
|
||||
skip: options?.offset ?? 0,
|
||||
filter: options?.filter as DataTableFilter | undefined,
|
||||
});
|
||||
},
|
||||
|
||||
async insertRows(dataTableId, rows) {
|
||||
async insertRows(dataTableId, rows, options) {
|
||||
assertNotReadOnly();
|
||||
const { projectId, tableName } = await resolveTableMeta(
|
||||
const { projectId, tableName, resolvedId } = await resolveTableMeta(
|
||||
['dataTable:writeRow'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
const result = await dataTableService.insertRows(
|
||||
dataTableId,
|
||||
resolvedId,
|
||||
projectId,
|
||||
rows as DataTableRows,
|
||||
'count',
|
||||
);
|
||||
return {
|
||||
insertedCount: typeof result === 'number' ? result : rows.length,
|
||||
dataTableId,
|
||||
dataTableId: resolvedId,
|
||||
tableName,
|
||||
projectId,
|
||||
};
|
||||
},
|
||||
|
||||
async updateRows(dataTableId, filter, data) {
|
||||
async updateRows(dataTableId, filter, data, options) {
|
||||
assertNotReadOnly();
|
||||
const { projectId, tableName } = await resolveTableMeta(
|
||||
const { projectId, tableName, resolvedId } = await resolveTableMeta(
|
||||
['dataTable:writeRow'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
const result = await dataTableService.updateRows(
|
||||
dataTableId,
|
||||
resolvedId,
|
||||
projectId,
|
||||
{ filter: filter as DataTableFilter, data: data as DataTableRow },
|
||||
true,
|
||||
);
|
||||
return {
|
||||
updatedCount: Array.isArray(result) ? result.length : 0,
|
||||
dataTableId,
|
||||
dataTableId: resolvedId,
|
||||
tableName,
|
||||
projectId,
|
||||
};
|
||||
},
|
||||
|
||||
async deleteRows(dataTableId, filter) {
|
||||
async deleteRows(dataTableId, filter, options) {
|
||||
assertNotReadOnly();
|
||||
const { projectId, tableName } = await resolveTableMeta(
|
||||
const { projectId, tableName, resolvedId } = await resolveTableMeta(
|
||||
['dataTable:writeRow'],
|
||||
dataTableId,
|
||||
options,
|
||||
);
|
||||
const result = await dataTableService.deleteRows(
|
||||
dataTableId,
|
||||
resolvedId,
|
||||
projectId,
|
||||
{ filter: filter as DataTableFilter },
|
||||
true,
|
||||
);
|
||||
return {
|
||||
deletedCount: Array.isArray(result) ? result.length : 0,
|
||||
dataTableId,
|
||||
dataTableId: resolvedId,
|
||||
tableName,
|
||||
projectId,
|
||||
};
|
||||
@@ -2158,6 +2226,83 @@ const MAX_RESULT_CHARS = 20_000;
|
||||
/** Maximum characters for a single node's output preview when truncating. */
|
||||
const MAX_NODE_OUTPUT_CHARS = 1_000;
|
||||
|
||||
/**
|
||||
* Minimal DataTable shape the resolver needs. Kept narrow so tests can mock
|
||||
* the repository without depending on the full TypeORM entity.
|
||||
*/
|
||||
interface DataTableRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface DataTableIdOrNameRepository {
|
||||
findOneBy: (where: { id: string }) => Promise<DataTableRecord | null>;
|
||||
findBy: (where: { name: string; projectId?: string }) => Promise<DataTableRecord[]>;
|
||||
}
|
||||
|
||||
interface DataTableResolverLogger {
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export type ResolveDataTableResult =
|
||||
| { kind: 'hit'; table: DataTableRecord }
|
||||
| { kind: 'miss' }
|
||||
| { kind: 'ambiguous'; candidates: DataTableRecord[] };
|
||||
|
||||
/**
|
||||
* Look up a data table by the orchestrator-supplied identifier. Tries `id`
|
||||
* first; if that misses, tries `name`. The name fallback exists because the
|
||||
* orchestrator occasionally passes the human-readable table name it saw in a
|
||||
* `data-tables list` response instead of the numeric id.
|
||||
*
|
||||
* When the caller provides an `accessFilter`, candidates the user cannot
|
||||
* access are filtered out BEFORE the ambiguity check — so a collision across
|
||||
* projects the caller can't see still resolves cleanly to the one they can.
|
||||
* `projectIdFilter` narrows the name lookup at the database level when the
|
||||
* caller already knows the target project (table names are unique per
|
||||
* project).
|
||||
*/
|
||||
export async function resolveDataTableByIdOrName(
|
||||
repository: DataTableIdOrNameRepository,
|
||||
logger: DataTableResolverLogger,
|
||||
idOrName: string,
|
||||
options?: {
|
||||
projectIdFilter?: string;
|
||||
accessFilter?: (id: string) => Promise<boolean>;
|
||||
},
|
||||
): Promise<ResolveDataTableResult> {
|
||||
const byId = await repository.findOneBy({ id: idOrName });
|
||||
if (byId) {
|
||||
if (options?.accessFilter && !(await options.accessFilter(byId.id))) {
|
||||
return { kind: 'miss' };
|
||||
}
|
||||
return { kind: 'hit', table: byId };
|
||||
}
|
||||
|
||||
const candidates = await repository.findBy({
|
||||
name: idOrName,
|
||||
...(options?.projectIdFilter ? { projectId: options.projectIdFilter } : {}),
|
||||
});
|
||||
let filtered = candidates;
|
||||
if (options?.accessFilter) {
|
||||
filtered = [];
|
||||
for (const c of candidates) {
|
||||
if (await options.accessFilter(c.id)) filtered.push(c);
|
||||
}
|
||||
}
|
||||
if (filtered.length === 0) return { kind: 'miss' };
|
||||
if (filtered.length > 1) return { kind: 'ambiguous', candidates: filtered };
|
||||
|
||||
const hit = filtered[0];
|
||||
logger.warn('data-tables tool called with table name instead of id — resolved by name fallback', {
|
||||
passedValue: idOrName,
|
||||
resolvedId: hit.id,
|
||||
projectId: hit.projectId,
|
||||
});
|
||||
return { kind: 'hit', table: hit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the `builderHint.message` of the property that references a given
|
||||
* method name via `@searchListMethod` (RLC list modes) or `@loadOptionsMethod`.
|
||||
@@ -2463,8 +2608,39 @@ function getExecutionModeForTrigger(node: INode): WorkflowExecuteMode {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that `inputData` matches the shape the caller of `verify-built-workflow`
|
||||
* is expected to pass for this trigger. Throws a descriptive error when the shape
|
||||
* is wrong — this is surfaced to the orchestrator via the execution result and
|
||||
* prevents the common "null downstream values" misdiagnosis where the orchestrator
|
||||
* would otherwise patch the workflow's expressions (breaking it in production).
|
||||
*/
|
||||
function validateInputDataShape(node: INode, inputData: Record<string, unknown>): void {
|
||||
if (node.type === FORM_TRIGGER_NODE_TYPE) {
|
||||
// Production Form Trigger emits field values FLAT on `json` alongside
|
||||
// `submittedAt` and `formMode`. Callers that place fields under `formFields`
|
||||
// (whether pure-wrap `{formFields: {...}}` or mixed-key `{formFields: {...},
|
||||
// other: ...}`) produce pin data where `$json.<field>` resolves to null and
|
||||
// downstream expressions look broken. Any top-level `formFields` object is
|
||||
// treated as a mistake — real Form Trigger fields are scalars and would not
|
||||
// surface as a nested object here.
|
||||
const formFieldsValue = inputData.formFields;
|
||||
const looksWrapped = typeof formFieldsValue === 'object' && formFieldsValue !== null;
|
||||
if (looksWrapped) {
|
||||
throw new Error(
|
||||
'verify-built-workflow: inputData for a Form Trigger must be a flat field map ' +
|
||||
'(e.g. {name: "Alice", email: "a@b.c"}), NOT wrapped in `formFields`. ' +
|
||||
'The production Form Trigger emits fields directly on $json, so downstream ' +
|
||||
'expressions like $json.name are correct. Re-run with the flat shape.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Construct proper pin data per trigger type. */
|
||||
function getPinDataForTrigger(node: INode, inputData: Record<string, unknown>): IPinData {
|
||||
validateInputDataShape(node, inputData);
|
||||
|
||||
switch (node.type) {
|
||||
case CHAT_TRIGGER_NODE_TYPE:
|
||||
return {
|
||||
@@ -2495,18 +2671,39 @@ function getPinDataForTrigger(node: INode, inputData: Record<string, unknown>):
|
||||
],
|
||||
};
|
||||
|
||||
case WEBHOOK_NODE_TYPE:
|
||||
case WEBHOOK_NODE_TYPE: {
|
||||
// Allow callers that already wrap the payload as an envelope
|
||||
// (`{body, headers?, query?}`) — unwrap once so the adapter's outer
|
||||
// `body: inputData` wrapper doesn't create double nesting. But only
|
||||
// treat `inputData` as an envelope when ALL top-level keys look like
|
||||
// envelope fields; otherwise a flat payload that happens to contain a
|
||||
// `body` field (e.g. `{event: 'signup', body: {...}}`) would have its
|
||||
// sibling fields silently dropped, producing the same "null downstream
|
||||
// values" failure the Form Trigger validator exists to prevent.
|
||||
const envelopeKeys = new Set(['body', 'headers', 'query']);
|
||||
const inputKeys = Object.keys(inputData);
|
||||
const looksLikeEnvelope =
|
||||
inputKeys.length > 0 &&
|
||||
inputKeys.every((k) => envelopeKeys.has(k)) &&
|
||||
typeof inputData.body === 'object' &&
|
||||
inputData.body !== null;
|
||||
const body = looksLikeEnvelope ? (inputData.body as Record<string, unknown>) : inputData;
|
||||
const headers =
|
||||
looksLikeEnvelope && typeof inputData.headers === 'object' && inputData.headers !== null
|
||||
? (inputData.headers as Record<string, unknown>)
|
||||
: {};
|
||||
const query =
|
||||
looksLikeEnvelope && typeof inputData.query === 'object' && inputData.query !== null
|
||||
? (inputData.query as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
[node.name]: [
|
||||
{
|
||||
json: {
|
||||
headers: {},
|
||||
query: {},
|
||||
body: inputData,
|
||||
},
|
||||
json: { headers, query, body },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case SCHEDULE_TRIGGER_NODE_TYPE: {
|
||||
const now = new Date();
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
PlannedTaskCoordinator,
|
||||
PlannedTaskStorage,
|
||||
applyPlannedTaskPermissions,
|
||||
PLANNED_TASK_PERMISSION_OVERRIDES,
|
||||
releaseTraceClient,
|
||||
submitLangsmithUserFeedback,
|
||||
resumeAgentRun,
|
||||
@@ -62,6 +63,7 @@ import {
|
||||
type PlannedTaskRecord,
|
||||
type SandboxConfig,
|
||||
type SpawnBackgroundTaskOptions,
|
||||
type SpawnBackgroundTaskResult,
|
||||
type ServiceProxyConfig,
|
||||
type StreamableAgent,
|
||||
type SuspendedRunState,
|
||||
@@ -187,6 +189,14 @@ export class InstanceAiService {
|
||||
/** Per-thread promise chain that serializes schedulePlannedTasks calls. */
|
||||
private readonly schedulerLocks = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Checkpoint re-entries that could not fire when their parent-tagged child
|
||||
* settled (an orchestrator run was live, or other parent siblings were
|
||||
* still running). Drained from the post-run cleanup path so the checkpoint
|
||||
* is never left orphaned.
|
||||
*/
|
||||
private readonly pendingCheckpointReentries = new Map<string, Set<string>>();
|
||||
|
||||
/** Periodic sweep that auto-rejects timed-out HITL confirmations. */
|
||||
private confirmationTimeoutInterval?: NodeJS.Timeout;
|
||||
|
||||
@@ -865,6 +875,13 @@ export class InstanceAiService {
|
||||
researchMode,
|
||||
});
|
||||
|
||||
// Persist the user's time zone so checkpoint / replan / synthesize
|
||||
// follow-up runs can reinject it into the planner and system prompt
|
||||
// instead of falling back to GENERIC_TIMEZONE.
|
||||
if (timeZone) {
|
||||
this.runState.setTimeZone(threadId, timeZone);
|
||||
}
|
||||
|
||||
if (pushRef !== undefined) {
|
||||
this.threadPushRef.set(threadId, pushRef);
|
||||
}
|
||||
@@ -936,6 +953,16 @@ export class InstanceAiService {
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any awaiting_approval plan graph for this thread. The user
|
||||
// cancelled before approving, so leaving the graph persisted would (a)
|
||||
// cause doSchedulePlannedTasks() to republish the stale checklist on
|
||||
// every later pass via syncPlannedTasksToUi(), and (b) incorrectly let a
|
||||
// future unrelated create-tasks call bypass the replan-only guard via
|
||||
// threadHasExistingPlan(). Only target awaiting_approval — active and
|
||||
// awaiting_replan graphs have their own settlement logic via the
|
||||
// background-task cancellations above.
|
||||
void this.cancelAwaitingApprovalPlan(threadId);
|
||||
|
||||
const { active, suspended } = this.runState.cancelThread(threadId);
|
||||
if (active) {
|
||||
active.abortController.abort();
|
||||
@@ -1237,9 +1264,9 @@ export class InstanceAiService {
|
||||
}
|
||||
|
||||
private buildPlannedTaskFollowUpMessage(
|
||||
type: 'synthesize' | 'replan',
|
||||
type: 'synthesize' | 'replan' | 'checkpoint',
|
||||
graph: PlannedTaskGraph,
|
||||
failedTask?: PlannedTaskRecord,
|
||||
options: { failedTask?: PlannedTaskRecord; checkpoint?: PlannedTaskRecord } = {},
|
||||
): string {
|
||||
const payload: Record<string, unknown> = {
|
||||
tasks: graph.tasks.map((task) => ({
|
||||
@@ -1253,13 +1280,32 @@ export class InstanceAiService {
|
||||
})),
|
||||
};
|
||||
|
||||
if (failedTask) {
|
||||
if (options.failedTask) {
|
||||
payload.failedTask = {
|
||||
id: failedTask.id,
|
||||
title: failedTask.title,
|
||||
kind: failedTask.kind,
|
||||
error: failedTask.error,
|
||||
result: failedTask.result,
|
||||
id: options.failedTask.id,
|
||||
title: options.failedTask.title,
|
||||
kind: options.failedTask.kind,
|
||||
error: options.failedTask.error,
|
||||
result: options.failedTask.result,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.checkpoint) {
|
||||
const depOutcomes = graph.tasks
|
||||
.filter((t) => options.checkpoint!.deps.includes(t.id))
|
||||
.map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
kind: t.kind,
|
||||
status: t.status,
|
||||
result: t.result,
|
||||
outcome: t.outcome,
|
||||
}));
|
||||
payload.checkpoint = {
|
||||
id: options.checkpoint.id,
|
||||
title: options.checkpoint.title,
|
||||
instructions: options.checkpoint.spec,
|
||||
dependsOn: depOutcomes,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1286,6 +1332,34 @@ export class InstanceAiService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop any persisted planned-task graph that is still `awaiting_approval`,
|
||||
* and clear the UI checklist. Called on run cancellation and HITL timeout so
|
||||
* stale approval state doesn't linger. A graph in `active` / `awaiting_replan`
|
||||
* is already in-flight and has its own settlement logic.
|
||||
*/
|
||||
private async cancelAwaitingApprovalPlan(threadId: string): Promise<void> {
|
||||
try {
|
||||
const { plannedTaskService, taskStorage } = await this.createPlannedTaskState();
|
||||
const graph = await plannedTaskService.getGraph(threadId);
|
||||
if (!graph || graph.status !== 'awaiting_approval') return;
|
||||
|
||||
await plannedTaskService.clear(threadId);
|
||||
await taskStorage.save(threadId, { tasks: [] });
|
||||
this.eventBus.publish(threadId, {
|
||||
type: 'tasks-update',
|
||||
runId: graph.planRunId,
|
||||
agentId: ORCHESTRATOR_AGENT_ID,
|
||||
payload: { tasks: { tasks: [] }, planItems: [] },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to clean up awaiting_approval plan on cancel', {
|
||||
threadId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async createExecutionEnvironment(
|
||||
user: User,
|
||||
threadId: string,
|
||||
@@ -1541,6 +1615,43 @@ export class InstanceAiService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the workflow IDs the checkpoint task is verifying so the runWorkflow
|
||||
* permission override can be scoped. Walks the checkpoint's `dependsOn` to find
|
||||
* the build-workflow tasks it depends on and reads their `outcome.workflowId`.
|
||||
* Returns an empty set when the graph is missing or the checkpoint has no
|
||||
* resolved workflow deps (in which case the override applies broadly via the
|
||||
* `allowList === undefined` short-circuit only if we don't set the field).
|
||||
*/
|
||||
private async getCheckpointAllowedWorkflowIds(
|
||||
threadId: string,
|
||||
checkpointTaskId: string,
|
||||
): Promise<ReadonlySet<string>> {
|
||||
try {
|
||||
const { plannedTaskService } = await this.createPlannedTaskState();
|
||||
const graph = await plannedTaskService.getGraph(threadId);
|
||||
const checkpoint = graph?.tasks.find((t) => t.id === checkpointTaskId);
|
||||
if (!graph || !checkpoint) return new Set();
|
||||
const deps = new Set(checkpoint.deps);
|
||||
const allowed = new Set<string>();
|
||||
for (const task of graph.tasks) {
|
||||
if (!deps.has(task.id)) continue;
|
||||
const workflowId = task.outcome?.workflowId;
|
||||
if (typeof workflowId === 'string' && workflowId.length > 0) {
|
||||
allowed.add(workflowId);
|
||||
}
|
||||
}
|
||||
return allowed;
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to resolve checkpoint allowed workflow IDs', {
|
||||
threadId,
|
||||
checkpointTaskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
private async handlePlannedTaskSettlement(
|
||||
user: User,
|
||||
task: ManagedBackgroundTask,
|
||||
@@ -1580,6 +1691,7 @@ export class InstanceAiService {
|
||||
researchMode: boolean | undefined,
|
||||
messageGroupId?: string,
|
||||
isReplanFollowUp: boolean = false,
|
||||
checkpoint?: { isCheckpointFollowUp: true; checkpointTaskId: string },
|
||||
): Promise<string> {
|
||||
if (this.runState.hasLiveRun(threadId)) {
|
||||
this.logger.warn('Skipping internal follow-up: active run exists', { threadId });
|
||||
@@ -1593,6 +1705,12 @@ export class InstanceAiService {
|
||||
messageGroupId,
|
||||
});
|
||||
|
||||
// Resolve user time zone from the thread's run-state snapshot (captured on the
|
||||
// initial user-facing run) before falling back to the instance default. Follow-up
|
||||
// runs (checkpoint / replan / synthesize) used to drop this context, which made
|
||||
// the planner emit "instance default timezone" for user-local schedules.
|
||||
const timeZone = this.runState.getTimeZone(threadId) ?? this.defaultTimeZone;
|
||||
|
||||
void this.executeRun(
|
||||
user,
|
||||
threadId,
|
||||
@@ -1602,8 +1720,9 @@ export class InstanceAiService {
|
||||
researchMode,
|
||||
undefined,
|
||||
messageGroupId,
|
||||
undefined,
|
||||
timeZone,
|
||||
isReplanFollowUp,
|
||||
checkpoint,
|
||||
);
|
||||
|
||||
return runId;
|
||||
@@ -1634,26 +1753,92 @@ export class InstanceAiService {
|
||||
|
||||
if (action.type === 'replan') {
|
||||
await this.syncPlannedTasksToUi(threadId, action.graph);
|
||||
await this.startInternalFollowUpRun(
|
||||
const startedRunId = await this.startInternalFollowUpRun(
|
||||
user,
|
||||
threadId,
|
||||
this.buildPlannedTaskFollowUpMessage('replan', action.graph, action.failedTask),
|
||||
this.buildPlannedTaskFollowUpMessage('replan', action.graph, {
|
||||
failedTask: action.failedTask,
|
||||
}),
|
||||
this.runState.getThreadResearchMode(threadId),
|
||||
action.graph.messageGroupId,
|
||||
true,
|
||||
);
|
||||
// tick() already transitioned the graph to `awaiting_replan`. If the
|
||||
// follow-up run couldn't start (live run present), revert the status
|
||||
// so the next schedulePlannedTasks() pass can re-emit this action.
|
||||
// Without this, tick() returns `none` for non-active graphs and the
|
||||
// replan is silently lost.
|
||||
if (!startedRunId) {
|
||||
await plannedTaskService.revertToActive(threadId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === 'synthesize') {
|
||||
await this.syncPlannedTasksToUi(threadId, action.graph);
|
||||
await this.startInternalFollowUpRun(
|
||||
const startedRunId = await this.startInternalFollowUpRun(
|
||||
user,
|
||||
threadId,
|
||||
this.buildPlannedTaskFollowUpMessage('synthesize', action.graph),
|
||||
this.runState.getThreadResearchMode(threadId),
|
||||
action.graph.messageGroupId,
|
||||
);
|
||||
// Same rollback as replan: tick() transitioned to `completed`, but if
|
||||
// the synthesize follow-up didn't actually start, revert so the next
|
||||
// tick can emit it again.
|
||||
if (!startedRunId) {
|
||||
await plannedTaskService.revertToActive(threadId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === 'orchestrate-checkpoint') {
|
||||
// Defer if a run is already active or suspended. The currently-live
|
||||
// run's post-finally reschedule hook will pick this checkpoint up.
|
||||
if (this.runState.hasLiveRun(threadId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkpoint = action.tasks[0];
|
||||
|
||||
// Mark running before starting the follow-up so complete-checkpoint
|
||||
// (which requires status === 'running') always sees the correct state.
|
||||
// If startInternalFollowUpRun no-ops below (tight race), we roll back
|
||||
// the transition to avoid leaving the task in a phantom 'running' state.
|
||||
await plannedTaskService.markRunning(threadId, checkpoint.id, {
|
||||
agentId: ORCHESTRATOR_AGENT_ID,
|
||||
});
|
||||
const graphAfterMark = (await plannedTaskService.getGraph(threadId)) ?? action.graph;
|
||||
await this.syncPlannedTasksToUi(threadId, graphAfterMark);
|
||||
|
||||
const checkpointRecord =
|
||||
graphAfterMark.tasks.find((t) => t.id === checkpoint.id) ?? checkpoint;
|
||||
|
||||
const startedRunId = await this.startInternalFollowUpRun(
|
||||
user,
|
||||
threadId,
|
||||
this.buildPlannedTaskFollowUpMessage('checkpoint', graphAfterMark, {
|
||||
checkpoint: checkpointRecord,
|
||||
}),
|
||||
this.runState.getThreadResearchMode(threadId),
|
||||
action.graph.messageGroupId,
|
||||
false,
|
||||
{ isCheckpointFollowUp: true, checkpointTaskId: checkpoint.id },
|
||||
);
|
||||
|
||||
if (!startedRunId) {
|
||||
// Rare race: the outer hasLiveRun check passed but the inner guard
|
||||
// in startInternalFollowUpRun did not (another path started a run
|
||||
// between our two checks). Revert the checkpoint back to `planned`
|
||||
// so the next scheduler tick re-emits `orchestrate-checkpoint` —
|
||||
// marking it `failed` here would cascade cancel to every dependent
|
||||
// and destroy downstream work even though nothing actually failed.
|
||||
this.logger.warn(
|
||||
'Checkpoint follow-up run did not start — reverting checkpoint to planned for retry',
|
||||
{ threadId, checkpointTaskId: checkpoint.id },
|
||||
);
|
||||
await plannedTaskService.revertCheckpointToPlanned(threadId, checkpoint.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1685,6 +1870,7 @@ export class InstanceAiService {
|
||||
messageGroupId?: string,
|
||||
timeZone?: string,
|
||||
isReplanFollowUp: boolean = false,
|
||||
checkpoint?: { isCheckpointFollowUp: true; checkpointTaskId: string },
|
||||
): Promise<void> {
|
||||
const signal = abortController.signal;
|
||||
let mastraRunId = '';
|
||||
@@ -1735,6 +1921,24 @@ export class InstanceAiService {
|
||||
orchestrationContext.isReplanFollowUp = isReplanFollowUp;
|
||||
orchestrationContext.timeZone = timeZone ?? this.defaultTimeZone;
|
||||
|
||||
if (checkpoint?.isCheckpointFollowUp) {
|
||||
orchestrationContext.isCheckpointFollowUp = true;
|
||||
orchestrationContext.checkpointTaskId = checkpoint.checkpointTaskId;
|
||||
// Plan approval authorizes verification; grant runWorkflow on the adapter context
|
||||
// because createInstanceAgent builds domain tools from `context`, not `orchestrationContext.domainContext`.
|
||||
context.permissions = {
|
||||
...context.permissions,
|
||||
...(PLANNED_TASK_PERMISSION_OVERRIDES.checkpoint ?? {}),
|
||||
} as typeof context.permissions;
|
||||
// Scope the runWorkflow override to the workflows this checkpoint is verifying:
|
||||
// the orchestrator can call `executions(action="run")` on a depended-on workflow
|
||||
// without HITL, but any other workflow id still requires user approval.
|
||||
context.allowedRunWorkflowIds = await this.getCheckpointAllowedWorkflowIds(
|
||||
threadId,
|
||||
checkpoint.checkpointTaskId,
|
||||
);
|
||||
}
|
||||
|
||||
// Thread attachments into the domain context so parse-file can access them
|
||||
if (attachments && attachments.length > 0) {
|
||||
context.currentUserAttachments = attachments;
|
||||
@@ -1804,7 +2008,6 @@ export class InstanceAiService {
|
||||
mcpServers,
|
||||
memoryConfig,
|
||||
memory,
|
||||
workspace: orchestrationContext.workspace,
|
||||
disableDeferredTools: true,
|
||||
timeZone: timeZone ?? this.defaultTimeZone,
|
||||
});
|
||||
@@ -2018,6 +2221,7 @@ export class InstanceAiService {
|
||||
messageGroupId,
|
||||
createdAt: Date.now(),
|
||||
tracing,
|
||||
checkpoint,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2142,9 +2346,215 @@ export class InstanceAiService {
|
||||
if (!this.runState.hasSuspendedRun(threadId) && mastraRunId) {
|
||||
void this.cleanupMastraSnapshots(mastraRunId);
|
||||
}
|
||||
// Post-run planned-task wiring (only when the run is actually ending,
|
||||
// not when it merely suspended for HITL):
|
||||
// 1. Checkpoint deadlock fallback — if this run was a checkpoint
|
||||
// follow-up and the orchestrator exited without calling
|
||||
// complete-checkpoint, mark the task failed so the scheduler
|
||||
// can transition to awaiting_replan.
|
||||
// 2. Unconditional reschedule — drive the next tick. This covers
|
||||
// the case where a background task settled during an ordinary
|
||||
// chat run: its schedulePlannedTasks call may have skipped the
|
||||
// checkpoint branch because hasLiveRun was true. Ticking again
|
||||
// now (with no live run) picks it up. schedulerLocks serializes
|
||||
// this call, and tick() is a no-op when no graph exists.
|
||||
if (!this.runState.hasSuspendedRun(threadId)) {
|
||||
if (checkpoint?.isCheckpointFollowUp) {
|
||||
await this.finalizeCheckpointFollowUp(user, threadId, checkpoint.checkpointTaskId);
|
||||
} else {
|
||||
await this.schedulePlannedTasks(user, threadId);
|
||||
}
|
||||
await this.drainPendingCheckpointReentries(user, threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post-run cleanup for a checkpoint follow-up. Ensures the checkpoint task is
|
||||
* terminal (marking it failed if the orchestrator abandoned it) and re-ticks
|
||||
* the scheduler so the next planned action can fire.
|
||||
*/
|
||||
private queuePendingCheckpointReentry(threadId: string, checkpointTaskId: string): void {
|
||||
let set = this.pendingCheckpointReentries.get(threadId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
this.pendingCheckpointReentries.set(threadId, set);
|
||||
}
|
||||
set.add(checkpointTaskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drain any checkpoint re-entries whose parent-tagged children settled while
|
||||
* an orchestrator run was live (or while other siblings were still running).
|
||||
* Called from the post-run cleanup path in every run-ending `finally` block,
|
||||
* so the checkpoint is never left orphaned when the settlement path could
|
||||
* not fire immediately.
|
||||
*/
|
||||
private async drainPendingCheckpointReentries(user: User, threadId: string): Promise<void> {
|
||||
const set = this.pendingCheckpointReentries.get(threadId);
|
||||
if (!set || set.size === 0) return;
|
||||
const snapshot = [...set];
|
||||
for (const checkpointTaskId of snapshot) {
|
||||
// If a new run started while we were draining, stop — the next run's
|
||||
// cleanup will pick up the remaining markers.
|
||||
if (this.runState.getActiveRunId(threadId) || this.runState.hasSuspendedRun(threadId)) {
|
||||
return;
|
||||
}
|
||||
// A new parent-tagged child is running — let its settlement drive the
|
||||
// checkpoint instead of racing another re-entry.
|
||||
const siblings = this.backgroundTasks.getRunningTasksByParentCheckpoint(
|
||||
threadId,
|
||||
checkpointTaskId,
|
||||
);
|
||||
if (siblings.length > 0) continue;
|
||||
set.delete(checkpointTaskId);
|
||||
await this.reenterCheckpointById(user, threadId, checkpointTaskId);
|
||||
}
|
||||
if (set.size === 0) this.pendingCheckpointReentries.delete(threadId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a synthetic `<planned-task-follow-up type="checkpoint">` for the
|
||||
* given checkpoint task id when the parent-tagged children that drove it
|
||||
* are no longer running and no new orchestrator run is live. Used by both
|
||||
* the immediate re-entry path (via `maybeReenterParentCheckpoint`) and the
|
||||
* deferred drain (via `drainPendingCheckpointReentries`).
|
||||
*/
|
||||
private async reenterCheckpointById(
|
||||
user: User,
|
||||
threadId: string,
|
||||
checkpointTaskId: string,
|
||||
messageGroupId?: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const { plannedTaskService } = await this.createPlannedTaskState();
|
||||
const graph = await plannedTaskService.getGraph(threadId);
|
||||
const checkpoint = graph?.tasks.find((t) => t.id === checkpointTaskId);
|
||||
if (!graph || !checkpoint || checkpoint.kind !== 'checkpoint') return false;
|
||||
if (checkpoint.status !== 'running') return false;
|
||||
|
||||
const startedRunId = await this.startInternalFollowUpRun(
|
||||
user,
|
||||
threadId,
|
||||
this.buildPlannedTaskFollowUpMessage('checkpoint', graph, { checkpoint }),
|
||||
this.runState.getThreadResearchMode(threadId),
|
||||
messageGroupId,
|
||||
false,
|
||||
{ isCheckpointFollowUp: true, checkpointTaskId },
|
||||
);
|
||||
if (!startedRunId) return false;
|
||||
this.logger.debug('Re-entered checkpoint follow-up', {
|
||||
threadId,
|
||||
checkpointTaskId,
|
||||
messageGroupId,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to re-enter checkpoint follow-up', {
|
||||
threadId,
|
||||
checkpointTaskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When a direct background task (builder/research/data-table/delegate)
|
||||
* settles and was spawned inside a checkpoint follow-up, try to re-enter
|
||||
* that checkpoint so the orchestrator can call `complete-checkpoint`.
|
||||
*
|
||||
* Returns `true` only when a follow-up was actually started. Returns
|
||||
* `false` in every other case (checkpoint no longer running, siblings
|
||||
* still in-flight, an orchestrator run is active or suspended, or the
|
||||
* graph no longer has the checkpoint). The caller is responsible for
|
||||
* queuing a deferred re-entry in the false case — never falling through
|
||||
* to a generic `<background-task-completed>` shell, which would re-open
|
||||
* the orphan bug.
|
||||
*/
|
||||
private async maybeReenterParentCheckpoint(
|
||||
user: User,
|
||||
threadId: string,
|
||||
task: ManagedBackgroundTask,
|
||||
): Promise<boolean> {
|
||||
const parentCheckpointId = task.parentCheckpointId;
|
||||
if (!parentCheckpointId) return false;
|
||||
|
||||
// If other parent-tagged children are still running, let the LAST one
|
||||
// re-drive the checkpoint; emitting multiple re-dispatches would race.
|
||||
const siblings = this.backgroundTasks
|
||||
.getRunningTasksByParentCheckpoint(threadId, parentCheckpointId)
|
||||
.filter((t) => t.taskId !== task.taskId);
|
||||
if (siblings.length > 0) return false;
|
||||
|
||||
// If a run is live, defer — startInternalFollowUpRun would be rejected
|
||||
// and we must not fall through to the shell path.
|
||||
if (this.runState.getActiveRunId(threadId) || this.runState.hasSuspendedRun(threadId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await this.reenterCheckpointById(
|
||||
user,
|
||||
threadId,
|
||||
parentCheckpointId,
|
||||
task.messageGroupId,
|
||||
);
|
||||
}
|
||||
|
||||
private async finalizeCheckpointFollowUp(
|
||||
user: User,
|
||||
threadId: string,
|
||||
checkpointTaskId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { plannedTaskService } = await this.createPlannedTaskState();
|
||||
const graph = await plannedTaskService.getGraph(threadId);
|
||||
const task = graph?.tasks.find((t) => t.id === checkpointTaskId);
|
||||
if (task && task.status === 'running') {
|
||||
// If the orchestrator spawned a detached sub-agent inside this
|
||||
// checkpoint's turn (builder, research, data-table, delegate) and
|
||||
// that child is still running, leave the checkpoint running. The
|
||||
// child's settlement path re-emits `orchestrate-checkpoint` so the
|
||||
// orchestrator re-enters the same checkpoint context and can then
|
||||
// call `complete-checkpoint`.
|
||||
const inflightChildren = this.backgroundTasks.getRunningTasksByParentCheckpoint(
|
||||
threadId,
|
||||
checkpointTaskId,
|
||||
);
|
||||
if (inflightChildren.length > 0) {
|
||||
this.logger.debug(
|
||||
'Checkpoint run ended with in-flight child tasks — deferring finalization',
|
||||
{
|
||||
threadId,
|
||||
checkpointTaskId,
|
||||
inflightTaskIds: inflightChildren.map((t) => t.taskId),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
this.logger.warn('Checkpoint run ended without reporting completion — marking failed', {
|
||||
threadId,
|
||||
checkpointTaskId,
|
||||
});
|
||||
await plannedTaskService.markCheckpointFailed(threadId, checkpointTaskId, {
|
||||
error: 'Checkpoint run ended without reporting completion',
|
||||
});
|
||||
const nextGraph = await plannedTaskService.getGraph(threadId);
|
||||
if (nextGraph) {
|
||||
await this.syncPlannedTasksToUi(threadId, nextGraph);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('Checkpoint finalization failed', {
|
||||
threadId,
|
||||
checkpointTaskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
|
||||
await this.schedulePlannedTasks(user, threadId);
|
||||
}
|
||||
|
||||
async resolveConfirmation(
|
||||
requestingUserId: string,
|
||||
requestId: string,
|
||||
@@ -2180,8 +2590,17 @@ export class InstanceAiService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { agent, runId, mastraRunId, threadId, user, toolCallId, abortController, tracing } =
|
||||
suspended;
|
||||
const {
|
||||
agent,
|
||||
runId,
|
||||
mastraRunId,
|
||||
threadId,
|
||||
user,
|
||||
toolCallId,
|
||||
abortController,
|
||||
tracing,
|
||||
checkpoint,
|
||||
} = suspended;
|
||||
if (user.id !== requestingUserId) return false;
|
||||
|
||||
this.runState.activateSuspendedRun(threadId);
|
||||
@@ -2213,6 +2632,7 @@ export class InstanceAiService {
|
||||
abortController,
|
||||
snapshotStorage: this.dbSnapshotStorage,
|
||||
tracing,
|
||||
checkpoint,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -2230,6 +2650,7 @@ export class InstanceAiService {
|
||||
abortController: AbortController;
|
||||
snapshotStorage: DbSnapshotStorage;
|
||||
tracing?: InstanceAiTraceContext;
|
||||
checkpoint?: { isCheckpointFollowUp: true; checkpointTaskId: string };
|
||||
},
|
||||
): Promise<void> {
|
||||
let messageTraceFinalization: MessageTraceFinalization | undefined;
|
||||
@@ -2289,6 +2710,7 @@ export class InstanceAiService {
|
||||
messageGroupId: this.traceContextsByRunId.get(opts.runId)?.messageGroupId,
|
||||
createdAt: Date.now(),
|
||||
tracing: opts.tracing,
|
||||
checkpoint: opts.checkpoint,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2407,6 +2829,22 @@ export class InstanceAiService {
|
||||
if (messageTraceFinalization) {
|
||||
await this.maybeFinalizeRunTraceRoot(opts.runId, messageTraceFinalization);
|
||||
}
|
||||
// Post-run planned-task wiring — mirror the executeRun finally.
|
||||
// Resumed ordinary-chat runs also need to drive the scheduler in case
|
||||
// a background task settled while they were active or suspended and
|
||||
// the orchestrate-checkpoint branch was skipped because of hasLiveRun.
|
||||
if (!this.runState.hasSuspendedRun(opts.threadId)) {
|
||||
if (opts.checkpoint?.isCheckpointFollowUp) {
|
||||
await this.finalizeCheckpointFollowUp(
|
||||
opts.user,
|
||||
opts.threadId,
|
||||
opts.checkpoint.checkpointTaskId,
|
||||
);
|
||||
} else {
|
||||
await this.schedulePlannedTasks(opts.user, opts.threadId);
|
||||
}
|
||||
await this.drainPendingCheckpointReentries(opts.user, opts.threadId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2417,8 +2855,8 @@ export class InstanceAiService {
|
||||
opts: SpawnBackgroundTaskOptions,
|
||||
snapshotStorage: DbSnapshotStorage,
|
||||
messageGroupIdOverride?: string,
|
||||
): void {
|
||||
this.backgroundTasks.spawn({
|
||||
): SpawnBackgroundTaskResult {
|
||||
const outcome = this.backgroundTasks.spawn({
|
||||
taskId: opts.taskId,
|
||||
threadId: opts.threadId,
|
||||
runId,
|
||||
@@ -2428,6 +2866,8 @@ export class InstanceAiService {
|
||||
plannedTaskId: opts.plannedTaskId,
|
||||
workItemId: opts.workItemId,
|
||||
traceContext: opts.traceContext,
|
||||
dedupeKey: opts.dedupeKey,
|
||||
parentCheckpointId: opts.parentCheckpointId,
|
||||
run: opts.run,
|
||||
onLimitReached: async (errorMessage) => {
|
||||
await this.finalizeDetachedTraceRun(opts.taskId, opts.traceContext, {
|
||||
@@ -2495,35 +2935,113 @@ export class InstanceAiService {
|
||||
// orchestrator run is active, resume the orchestrator so it can
|
||||
// synthesize results for the user. Planned tasks handle this via
|
||||
// schedulePlannedTasks(); this covers direct build-workflow-with-agent calls.
|
||||
if (!task.plannedTaskId) {
|
||||
const remaining = this.backgroundTasks.getRunningTasks(opts.threadId);
|
||||
const hasActiveRun = !!this.runState.getActiveRunId(opts.threadId);
|
||||
const hasSuspendedRun = this.runState.hasSuspendedRun(opts.threadId);
|
||||
if (remaining.length === 0 && !hasActiveRun && !hasSuspendedRun) {
|
||||
const user = this.runState.getThreadUser(opts.threadId);
|
||||
if (user) {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
role: opts.role,
|
||||
status: task.result ? 'completed' : task.error ? 'failed' : 'finished',
|
||||
result: task.result ?? undefined,
|
||||
error: task.error ?? undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
await this.startInternalFollowUpRun(
|
||||
user,
|
||||
opts.threadId,
|
||||
`<background-task-completed>\n${payload}\n</background-task-completed>\n\n${AUTO_FOLLOW_UP_MESSAGE}`,
|
||||
this.runState.getThreadResearchMode(opts.threadId),
|
||||
task.messageGroupId,
|
||||
);
|
||||
}
|
||||
if (task.plannedTaskId) return;
|
||||
|
||||
// Parent-tagged children (patch-builder etc. spawned inside a
|
||||
// checkpoint follow-up) must NEVER emit a generic
|
||||
// `<background-task-completed>` shell — the orchestrator would
|
||||
// land outside the checkpoint context and the checkpoint would
|
||||
// be orphaned. Try immediate re-entry; if the run state or
|
||||
// still-running siblings block it, queue a deferred marker that
|
||||
// the post-run drain hook will pick up.
|
||||
const parentCheckpointId = task.parentCheckpointId;
|
||||
if (parentCheckpointId) {
|
||||
const user = this.runState.getThreadUser(opts.threadId);
|
||||
if (!user) {
|
||||
this.queuePendingCheckpointReentry(opts.threadId, parentCheckpointId);
|
||||
return;
|
||||
}
|
||||
const reentered = await this.maybeReenterParentCheckpoint(user, opts.threadId, task);
|
||||
if (!reentered) {
|
||||
this.queuePendingCheckpointReentry(opts.threadId, parentCheckpointId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const remaining = this.backgroundTasks.getRunningTasks(opts.threadId);
|
||||
const hasActiveRun = !!this.runState.getActiveRunId(opts.threadId);
|
||||
const hasSuspendedRun = this.runState.hasSuspendedRun(opts.threadId);
|
||||
if (remaining.length === 0 && !hasActiveRun && !hasSuspendedRun) {
|
||||
const user = this.runState.getThreadUser(opts.threadId);
|
||||
if (user) {
|
||||
const payload = JSON.stringify(
|
||||
{
|
||||
role: opts.role,
|
||||
status: task.result ? 'completed' : task.error ? 'failed' : 'finished',
|
||||
result: task.result ?? undefined,
|
||||
outcome: task.outcome ?? undefined,
|
||||
error: task.error ?? undefined,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
await this.startInternalFollowUpRun(
|
||||
user,
|
||||
opts.threadId,
|
||||
`<background-task-completed>\n${payload}\n</background-task-completed>\n\n${AUTO_FOLLOW_UP_MESSAGE}`,
|
||||
this.runState.getThreadResearchMode(opts.threadId),
|
||||
task.messageGroupId,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (outcome.status === 'started') {
|
||||
return { status: 'started', taskId: outcome.task.taskId, agentId: outcome.task.agentId };
|
||||
}
|
||||
if (outcome.status === 'duplicate') {
|
||||
this.logger.warn('Background task dispatch deduped — task already in flight', {
|
||||
threadId: opts.threadId,
|
||||
requestedTaskId: opts.taskId,
|
||||
existingTaskId: outcome.existing.taskId,
|
||||
plannedTaskId: opts.dedupeKey?.plannedTaskId,
|
||||
workflowId: opts.dedupeKey?.workflowId,
|
||||
role: opts.role,
|
||||
});
|
||||
// The sub-agent dispatch tools publish `agent-spawned` and allocate a
|
||||
// detached LangSmith trace root BEFORE calling spawnBackgroundTask, so
|
||||
// the freshly-generated subAgentId for this deduped attempt already has
|
||||
// a phantom sub-agent node in the event stream and an unfinished trace
|
||||
// root. Compensate the same way `onLimitReached` does so the agent tree
|
||||
// snapshot doesn't keep a ghost child and the trace client is released.
|
||||
void this.finalizeDetachedTraceRun(opts.taskId, opts.traceContext, {
|
||||
status: 'cancelled',
|
||||
outputs: {
|
||||
taskId: opts.taskId,
|
||||
agentId: opts.agentId,
|
||||
role: opts.role,
|
||||
deduped_to: outcome.existing.taskId,
|
||||
},
|
||||
metadata: {
|
||||
deduped: true,
|
||||
existing_task_id: outcome.existing.taskId,
|
||||
...(opts.plannedTaskId ? { planned_task_id: opts.plannedTaskId } : {}),
|
||||
...(opts.workItemId ? { work_item_id: opts.workItemId } : {}),
|
||||
},
|
||||
});
|
||||
this.eventBus.publish(opts.threadId, {
|
||||
type: 'agent-completed',
|
||||
runId,
|
||||
agentId: opts.agentId,
|
||||
payload: {
|
||||
role: opts.role,
|
||||
result: '',
|
||||
error: `Deduped: task already in flight as ${outcome.existing.taskId}`,
|
||||
},
|
||||
});
|
||||
return {
|
||||
status: 'duplicate',
|
||||
existing: {
|
||||
taskId: outcome.existing.taskId,
|
||||
agentId: outcome.existing.agentId,
|
||||
role: outcome.existing.role,
|
||||
plannedTaskId: outcome.existing.plannedTaskId,
|
||||
workItemId: outcome.existing.workItemId,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { status: 'limit-reached' };
|
||||
}
|
||||
|
||||
private async buildMessageWithRunningTasks(threadId: string, message: string): Promise<string> {
|
||||
|
||||
@@ -5459,6 +5459,7 @@
|
||||
"instanceAi.tools.update-tasks": "Updating tasks",
|
||||
"instanceAi.tools.report-verification-verdict": "Verifying workflow",
|
||||
"instanceAi.tools.verify-built-workflow": "Verifying workflow",
|
||||
"instanceAi.tools.complete-checkpoint": "Completing checkpoint",
|
||||
"instanceAi.tools.updateWorkingMemory": "Updating memory",
|
||||
"instanceAi.tools.apply-workflow-credentials": "Applying credentials",
|
||||
"instanceAi.tools.setup-workflow": "Setting up workflow",
|
||||
|
||||
@@ -29,6 +29,10 @@ function makeToolCall(overrides: Partial<InstanceAiToolCallState> = {}): Instanc
|
||||
}
|
||||
|
||||
describe('getToolIcon', () => {
|
||||
test('returns circle-check for complete-checkpoint', () => {
|
||||
expect(getToolIcon('complete-checkpoint')).toBe('circle-check');
|
||||
});
|
||||
|
||||
test('returns share for delegate', () => {
|
||||
expect(getToolIcon('delegate')).toBe('share');
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { InstanceAiToolCallState } from '@n8n/api-types';
|
||||
const NO_TOGGLE_TOOLS = new Set(['updateWorkingMemory', 'plan', 'task-control']);
|
||||
|
||||
export function getToolIcon(toolName: string): IconName {
|
||||
if (toolName === 'complete-checkpoint') return 'circle-check';
|
||||
if (toolName === 'delegate' || toolName.endsWith('-with-agent')) return 'share';
|
||||
if (toolName === 'data-tables') return 'table';
|
||||
if (
|
||||
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1728"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=OybM2FjqjIz6T6tN31TIFl2YjwTqHiSz8z6MuDVmKzc-1777023316.203343-1.0.1.1-8VflBFRT1EhfFqZJcS7gnt9TDnA2jdsO2Lrmrye_me4; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1735"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaNNKxaidbpTTboGh6BcD"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-24T09:35:16Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26981000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-24T09:35:16Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-24T09:35:16Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-24T09:35:16Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22481000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Fri, 24 Apr 2026 09:35:18 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f141dee4e1fb8e3-BCN"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "OybM2FjqjIz6T6tN31TIFl2YjwTqHiSz8z6MuDVmKzc-1777023316.203343-1.0.1.1-8VflBFRT1EhfFqZJcS7gnt9TDnA2jdsO2Lrmrye_me4"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01NYL5pYTwbaaguHFC9HcnBZ\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":337,\"cache_creation_input_tokens\":10602,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":10602,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":53,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01BpaX4k3GussxVenU1Bv1b8\",\"name\":\"workflows\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"actio\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"n\\\": \\\"l\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ist\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"query\\\": \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"B3 F\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ull \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Wiza\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"rd Apply\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":337,\"cache_creation_input_tokens\":10602,\"cache_read_input_tokens\":0,\"output_tokens\":75} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxTllMNXBZVHdiYWFndUhGQzlIY25CWiIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjMzNywiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjoxMDYwMiwiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOjAsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjEwNjAyLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6NTMsInNlcnZpY2VfdGllciI6InN0YW5kYXJkIiwiaW5mZXJlbmNlX2dlbyI6Imdsb2JhbCJ9fSAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdGFydCIsImluZGV4IjowLCJjb250ZW50X2Jsb2NrIjp7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfMDFCcGFYNGszR3Vzc3hWZW5VMUJ2MWI4IiwibmFtZSI6IndvcmtmbG93cyIsImlucHV0Ijp7fSwiY2FsbGVyIjp7InR5cGUiOiJkaXJlY3QifX0gfQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IntcImFjdGlvIn0gfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6Im5cIjogXCJsIn0gICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJpc3RcIiJ9IH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiIsIFwicXVlcnlcIjogIn0gICAgICAgICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IlwiQjMgRiJ9IH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJ1bGwgIn0gfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IldpemEifSAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6InJkIEFwcGx5XCJ9In0gICAgICAgICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RvcApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0b3AiLCJpbmRleCI6MCAgICAgICAgICAgICB9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiJtZXNzYWdlX2RlbHRhIiwiZGVsdGEiOnsic3RvcF9yZWFzb24iOiJ0b29sX3VzZSIsInN0b3Bfc2VxdWVuY2UiOm51bGwsInN0b3BfZGV0YWlscyI6bnVsbH0sInVzYWdlIjp7ImlucHV0X3Rva2VucyI6MzM3LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjEwNjAyLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MCwib3V0cHV0X3Rva2VucyI6NzV9ICAgICAgICAgICAgICAgfQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiJtZXNzYWdlX3N0b3AiICAgICAgICAgICAgIH0KCg==",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777023322118-unknown-host-POST-_v1_messages-8a23f6c2.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1332"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=dR3cKPVgSCe3Kvs94W2Aeo1rC0dxaNIKRTCn62hwXYA-1777023318.7123675-1.0.1.1-96TCOCTyCc9HsnYOmgcjpBsS7UtnoZVJNEHx62fN9kM; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1337"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaNNL9JeGVNYKxYz8X8k4"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-24T09:35:18Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26981000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-24T09:35:18Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-24T09:35:18Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-24T09:35:18Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22481000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Fri, 24 Apr 2026 09:35:20 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f141dfdf941307e-BCN"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "dR3cKPVgSCe3Kvs94W2Aeo1rC0dxaNIKRTCn62hwXYA-1777023318.7123675-1.0.1.1-96TCOCTyCc9HsnYOmgcjpBsS7UtnoZVJNEHx62fN9kM"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_012FhR4GJPSsujZfDXcyqYQd\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":532,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":10602,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":68,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_0129xdQiT8FNaf9NzbAsBxoH\",\"name\":\"workflows\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"ac\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"tion\\\": \\\"s\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"etu\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"p\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"workflo\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"wId\\\": \\\"We\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Q1q\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"GrgcTD1\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"oaA8\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":532,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":10602,\"output_tokens\":84} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxMkZoUjRHSlBTc3VqWmZEWGN5cVlRZCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjUzMiwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTA2MDIsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjo2OCwic2VydmljZV90aWVyIjoic3RhbmRhcmQiLCJpbmZlcmVuY2VfZ2VvIjoiZ2xvYmFsIn19ICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0YXJ0IiwiaW5kZXgiOjAsImNvbnRlbnRfYmxvY2siOnsidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV8wMTI5eGRRaVQ4Rk5hZjlOemJBc0J4b0giLCJuYW1lIjoid29ya2Zsb3dzIiwiaW5wdXQiOnt9LCJjYWxsZXIiOnsidHlwZSI6ImRpcmVjdCJ9fSAgICAgICAgIH0KCmV2ZW50OiBwaW5nCmRhdGE6IHsidHlwZSI6ICJwaW5nIn0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiIifSAgICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoie1wiYWMifSAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6InRpb25cIjogXCJzIn0gICAgICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoiZXR1In0gICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6InBcIiJ9ICAgICAgICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiIsIFwid29ya2ZsbyJ9ICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoid0lkXCI6IFwiV2UifSAgICAgICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IlExcSJ9ICAgICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoiR3JnY1REMSJ9ICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJvYUE4XCJ9In0gIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjAgICB9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiJtZXNzYWdlX2RlbHRhIiwiZGVsdGEiOnsic3RvcF9yZWFzb24iOiJ0b29sX3VzZSIsInN0b3Bfc2VxdWVuY2UiOm51bGwsInN0b3BfZGV0YWlscyI6bnVsbH0sInVzYWdlIjp7ImlucHV0X3Rva2VucyI6NTMyLCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjoxMDYwMiwib3V0cHV0X3Rva2VucyI6ODR9ICAgICAgICAgICB9CgpldmVudDogbWVzc2FnZV9zdG9wCmRhdGE6IHsidHlwZSI6Im1lc3NhZ2Vfc3RvcCIgICAgICAgICAgICAgIH0KCg==",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777023322119-unknown-host-POST-_v1_messages-8a23f6c2.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "REGEX",
|
||||
"regex": "[\\s\\S]*\\[\\{\"type\":\"text\",\"text\":\"You are an expert n8n workflow builder\\. You generate com[\\s\\S]*\"type\"\\s*:\\s*\"tool_use\"[\\s\\S]{0,300}\"name\"\\s*:\\s*\"build-workflow\"[\\s\\S]*"
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1027"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=dR33gMXkzAc6j93mq2ZP3OP4FErgVaPt3SdJgr02y6Q-1777373966.9623053-1.0.1.1-53qNyPi9.nhalsyxtD_3E9Hd2o3Oa.lByByK6yec8yk; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1031"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaW3ywEz1up5JV3ACYHNi"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-28T10:59:27Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26966000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-28T10:59:27Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-28T10:59:27Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-28T10:59:27Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22466000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Tue, 28 Apr 2026 10:59:28 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f358ebd8c03b58b-PRG"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "dR33gMXkzAc6j93mq2ZP3OP4FErgVaPt3SdJgr02y6Q-1777373966.9623053-1.0.1.1-53qNyPi9.nhalsyxtD_3E9Hd2o3Oa.lByByK6yec8yk"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01TMAHdn4NsZzBxnR9hqa9JW\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":695,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":21523,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":3,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Workflow is\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" ready. Click the Manual Trigger and hit \\\"Test workflow\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"\\\" to run it — then customize the Set node to add whatever fields you need.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":695,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":21523,\"output_tokens\":35} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxVE1BSGRuNE5zWnpCeG5SOWhxYTlKVyIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjY5NSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MjE1MjMsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjozLCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0gICB9CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0YXJ0IiwiaW5kZXgiOjAsImNvbnRlbnRfYmxvY2siOnsidHlwZSI6InRleHQiLCJ0ZXh0IjoiIn0gICAgICAgfQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiJXb3JrZmxvdyBpcyJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiIgcmVhZHkuIENsaWNrIHRoZSBNYW51YWwgVHJpZ2dlciBhbmQgaGl0IFwiVGVzdCB3b3JrZmxvdyJ9ICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiJcIiB0byBydW4gaXQg4oCUIHRoZW4gY3VzdG9taXplIHRoZSBTZXQgbm9kZSB0byBhZGQgd2hhdGV2ZXIgZmllbGRzIHlvdSBuZWVkLiJ9ICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjAgICAgICAgICAgICAgICB9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiJtZXNzYWdlX2RlbHRhIiwiZGVsdGEiOnsic3RvcF9yZWFzb24iOiJlbmRfdHVybiIsInN0b3Bfc2VxdWVuY2UiOm51bGwsInN0b3BfZGV0YWlscyI6bnVsbH0sInVzYWdlIjp7ImlucHV0X3Rva2VucyI6Njk1LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjoyMTUyMywib3V0cHV0X3Rva2VucyI6MzV9ICAgfQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiJtZXNzYWdlX3N0b3AiICAgICAgICAgfQoK",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777373979523-unknown-host-POST-_v1_messages-e7a67275.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "REGEX",
|
||||
"regex": "[\\s\\S]*\\[\\{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde[\\s\\S]*Set up the workflow named \\\\\"B3 Full Wizard Apply\\\\\"\\.[\\s\\S]*"
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1129"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=pnbMU0JKL6VIeGtoQmnMJjyYTJBjJYTB19126AmlRvI-1777373973.2591949-1.0.1.1-JHRmYS_Sh_ZJas5yscrNAHLciVGqvmac3_N.GZu4DgI; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1131"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaW3zQAEertU27yw7WpZR"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-28T10:59:33Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26975000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-28T10:59:33Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-28T10:59:33Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-28T10:59:33Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22475000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Tue, 28 Apr 2026 10:59:34 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f358ee4d86ddf72-PRG"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "pnbMU0JKL6VIeGtoQmnMJjyYTJBjJYTB19126AmlRvI-1777373973.2591949-1.0.1.1-JHRmYS_Sh_ZJas5yscrNAHLciVGqvmac3_N.GZu4DgI"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01KTnakuKpWLGnRMwaQzgKdV\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":337,\"cache_creation_input_tokens\":14091,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":14091,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Let\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" me look up that workflow first.\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":1,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01A94TsW82xTinxnAX4yAVF6\",\"name\":\"workflows\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"act\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"io\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"n\\\": \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"list\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"query\\\": \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"B3 \"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Full Wiza\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"rd Apply\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":1,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":1 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":337,\"cache_creation_input_tokens\":14091,\"cache_read_input_tokens\":0,\"output_tokens\":83} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxS1RuYWt1S3BXTEduUk13YVF6Z0tkViIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjMzNywiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjoxNDA5MSwiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOjAsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjE0MDkxLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6MSwic2VydmljZV90aWVyIjoic3RhbmRhcmQiLCJpbmZlcmVuY2VfZ2VvIjoiZ2xvYmFsIn19ICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0YXJ0IiwiaW5kZXgiOjAsImNvbnRlbnRfYmxvY2siOnsidHlwZSI6InRleHQiLCJ0ZXh0IjoiIn0gICAgICAgfQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiJMZXQifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiIG1lIGxvb2sgdXAgdGhhdCB3b3JrZmxvdyBmaXJzdC4ifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjAgICAgICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdGFydCIsImluZGV4IjoxLCJjb250ZW50X2Jsb2NrIjp7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfMDFBOTRUc1c4MnhUaW54bkFYNHlBVkY2IiwibmFtZSI6IndvcmtmbG93cyIsImlucHV0Ijp7fSwiY2FsbGVyIjp7InR5cGUiOiJkaXJlY3QifX0gICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjoxLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IiJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjoxLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IntcImFjdCJ9ICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MSwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJpbyJ9ICAgICAgICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MSwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJuXCI6ICJ9ICAgICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjEsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoiXCJsaXN0XCIifSAgICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MSwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiIsIFwicXVlcnlcIjogIn0gIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MSwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJcIkIzICJ9IH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MSwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJGdWxsIFdpemEifSAgICAgICAgICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjoxLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6InJkIEFwcGx5In0gICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjEsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoiXCJ9In0gICAgICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RvcCIsImluZGV4IjoxICAgICAgICAgICB9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiJtZXNzYWdlX2RlbHRhIiwiZGVsdGEiOnsic3RvcF9yZWFzb24iOiJ0b29sX3VzZSIsInN0b3Bfc2VxdWVuY2UiOm51bGwsInN0b3BfZGV0YWlscyI6bnVsbH0sInVzYWdlIjp7ImlucHV0X3Rva2VucyI6MzM3LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjE0MDkxLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MCwib3V0cHV0X3Rva2VucyI6ODN9ICAgIH0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdG9wIiAgICAgIH0KCg==",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777373979524-unknown-host-POST-_v1_messages-abc49c4f.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "REGEX",
|
||||
"regex": "[\\s\\S]*\\[\\{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde[\\s\\S]*\"type\"\\s*:\\s*\"tool_use\"[\\s\\S]{0,300}\"name\"\\s*:\\s*\"workflows\"[\\s\\S]{0,500}\"action\"\\s*:\\s*\"list\"[\\s\\S]*"
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1417"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=7zOcfTVfjLjlOdvU7FDFjOiBGngj.K.vU1IjWO.Cf3U-1777373975.836431-1.0.1.1-VJw6NTGZjJMQCc02tAtKZbsaW0ISTztfRQAjr6vOBEg; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaW3zbFjE4WwUkSNComLY"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-28T10:59:35Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26975000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-28T10:59:35Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-28T10:59:35Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-28T10:59:36Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22475000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Tue, 28 Apr 2026 10:59:37 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f358ef4fb3cbb9c-PRG"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "7zOcfTVfjLjlOdvU7FDFjOiBGngj.K.vU1IjWO.Cf3U-1777373975.836431-1.0.1.1-VJw6NTGZjJMQCc02tAtKZbsaW0ISTztfRQAjr6vOBEg"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_018sM6MviEiG3T1QP1ktkjqj\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":541,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":14091,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":68,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01Qm84eUUf1P3CfD4Yk56bSc\",\"name\":\"workflows\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"act\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"ion\\\": \\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"setup\\\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\", \\\"work\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"flow\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"Id\\\": \\\"cKA\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"KMg8cquA\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"9y35\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"K\\\"}\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":541,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":14091,\"output_tokens\":84} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"}\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxOHNNNk12aUVpRzNUMVFQMWt0a2pxaiIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjU0MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTQwOTEsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjo2OCwic2VydmljZV90aWVyIjoic3RhbmRhcmQiLCJpbmZlcmVuY2VfZ2VvIjoiZ2xvYmFsIn19ICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdGFydCIsImluZGV4IjowLCJjb250ZW50X2Jsb2NrIjp7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfMDFRbTg0ZVVVZjFQM0NmRDRZazU2YlNjIiwibmFtZSI6IndvcmtmbG93cyIsImlucHV0Ijp7fSwiY2FsbGVyIjp7InR5cGUiOiJkaXJlY3QifX0gICAgICAgICAgICAgfQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IiJ9IH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJ7XCIifSAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoiYWN0In0gICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoiaW9uXCI6IFwiIn0gICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6InNldHVwXCIifSAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IiwgXCJ3b3JrIn0gICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJmbG93In0gICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJJZFwiOiBcImNLQSJ9IH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiJLTWc4Y3F1QSJ9ICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiI5eTM1In0gICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJpbnB1dF9qc29uX2RlbHRhIiwicGFydGlhbF9qc29uIjoiS1wifSJ9ICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RvcCIsImluZGV4IjowICAgICAgICAgICAgfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoidG9vbF91c2UiLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGx9LCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjU0MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTQwOTEsIm91dHB1dF90b2tlbnMiOjg0fSAgICAgICB9CgpldmVudDogbWVzc2FnZV9zdG9wCmRhdGE6IHsidHlwZSI6Im1lc3NhZ2Vfc3RvcCJ9Cgo=",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777373979525-unknown-host-POST-_v1_messages-c17e02f9.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
{"kind":"header","version":1,"testName":"recording","recordedAt":"2026-04-24T09:35:15.766Z"}
|
||||
{"kind":"tool-call","stepId":1,"agentRole":"orchestrator","toolName":"workflows","input":{"action":"list","query":"B3 Full Wizard Apply"},"output":{"workflows":[{"id":"WeQ1qGrgcTD1oaA8","name":"B3 Full Wizard Apply","versionId":"ddd666a7-e12a-4e98-89c8-98cdb6495738","activeVersionId":null,"createdAt":"2026-04-24T09:35:14.256Z","updatedAt":"2026-04-24T09:35:14.256Z"}]}}
|
||||
{"kind":"tool-call","stepId":2,"agentRole":"orchestrator","toolName":"workflows","input":{"action":"setup","workflowId":"WeQ1qGrgcTD1oaA8"},"output":{"success":false}}
|
||||
{"kind":"tool-resume","stepId":3,"agentRole":"orchestrator","toolName":"workflows","input":{"action":"setup","workflowId":"WeQ1qGrgcTD1oaA8"},"output":{"success":true,"completedNodes":[{"nodeName":"HTTP Request","parametersSet":["url"]}],"updatedNodes":[{"id":"trigger","name":"Manual Trigger","type":"n8n-nodes-base.manualTrigger","typeVersion":1,"position":[0,0],"parameters":{}},{"id":"http","name":"HTTP Request","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[220,-120],"parameters":{"method":"GET","url":"https://example.com/api","authentication":"none"}},{"id":"slack","name":"Slack","type":"n8n-nodes-base.slack","typeVersion":2.3,"position":[220,120],"parameters":{"resource":"message","operation":"post","select":"channel","channelId":{"__rl":true,"value":"C0000000000","mode":"id"},"text":"hello from setup e2e"},"credentials":{"slackApi":{"id":"L3ha3TfgeHqrnmgk","name":"B3 Apply Slack Primary"}}}],"updatedConnections":{"Manual Trigger":{"main":[[{"node":"HTTP Request","type":"main","index":0},{"node":"Slack","type":"main","index":0}]]}}},"resumeData":{"approved":true,"credentials":{},"action":"apply","nodeParameters":{"HTTP Request":{"url":"https://example.com/api"}}}}
|
||||
{"kind":"header","version":1,"testName":"recording","recordedAt":"2026-04-28T10:59:33.145Z"}
|
||||
{"kind":"tool-call","stepId":1,"agentRole":"orchestrator","toolName":"workflows","input":{"action":"list","query":"B3 Full Wizard Apply"},"output":{"workflows":[{"id":"cKAKMg8cquA9y35K","name":"B3 Full Wizard Apply","versionId":"1e867e5e-50fd-4326-8c0f-6ad69d9616aa","activeVersionId":null,"createdAt":"2026-04-28T10:59:29.306Z","updatedAt":"2026-04-28T10:59:29.306Z"}]}}
|
||||
{"kind":"tool-call","stepId":2,"agentRole":"orchestrator","toolName":"workflows","input":{"action":"setup","workflowId":"cKAKMg8cquA9y35K"},"output":{"success":false}}
|
||||
{"kind":"tool-resume","stepId":3,"agentRole":"orchestrator","toolName":"workflows","input":{"action":"setup","workflowId":"cKAKMg8cquA9y35K"},"output":{"success":true,"completedNodes":[{"nodeName":"HTTP Request","parametersSet":["url"]}],"updatedNodes":[{"id":"trigger","name":"Manual Trigger","type":"n8n-nodes-base.manualTrigger","typeVersion":1,"position":[0,0],"parameters":{}},{"id":"http","name":"HTTP Request","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[220,-120],"parameters":{"method":"GET","url":"https://example.com/api","authentication":"none"}},{"id":"slack","name":"Slack","type":"n8n-nodes-base.slack","typeVersion":2.3,"position":[220,120],"parameters":{"resource":"message","operation":"post","select":"channel","channelId":{"__rl":true,"value":"C0000000000","mode":"id"},"text":"hello from setup e2e"},"credentials":{"slackApi":{"id":"oR4HYz4azgE1NUGj","name":"B3 Apply Slack Primary"}}}],"updatedConnections":{"Manual Trigger":{"main":[[{"node":"HTTP Request","type":"main","index":0},{"node":"Slack","type":"main","index":0}]]}}},"resumeData":{"approved":true,"credentials":{},"action":"apply","nodeParameters":{"HTTP Request":{"url":"https://example.com/api"}}}}
|
||||
|
||||
-62
File diff suppressed because one or more lines are too long
-62
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["2144"],
|
||||
"vary": ["Accept-Encoding"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=hYM2Wjb_E2XYeEiakXKYruxDi85_0iAQHotJYYL_rh4-1775805976.061354-1.0.1.1-DJQ9KbS5gpIlwRS7Bnrli3dkRhFeJe0z85aEghEjKIw; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=2145"],
|
||||
"request-id": ["req_011CZuhAap989dhULJXrTQar"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-10T07:26:16Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["26976000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-10T07:26:16Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-10T07:26:16Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-10T07:26:16Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22476000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Fri, 10 Apr 2026 07:26:18 GMT"],
|
||||
"Content-Type": ["text/event-stream; charset=utf-8"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"Cache-Control": ["no-cache"],
|
||||
"CF-RAY": ["9ea005b66d9733a5-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "hYM2Wjb_E2XYeEiakXKYruxDi85_0iAQHotJYYL_rh4-1775805976.061354-1.0.1.1-DJQ9KbS5gpIlwRS7Bnrli3dkRhFeJe0z85aEghEjKIw"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01Brf49UhYvAGoWQ1imjuWtG\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1269,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":12360,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Building\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" your \\\"preview auto-open test\\\" workflow now!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1269,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":12360,\"output_tokens\":15} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxQnJmNDlVaFl2QUdvV1ExaW1qdVd0RyIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjEyNjksImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOjEyMzYwLCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjowLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6MSwic2VydmljZV90aWVyIjoic3RhbmRhcmQiLCJpbmZlcmVuY2VfZ2VvIjoiZ2xvYmFsIn19ICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0YXJ0IiwiaW5kZXgiOjAsImNvbnRlbnRfYmxvY2siOnsidHlwZSI6InRleHQiLCJ0ZXh0IjoiIn0gICAgICAgICAgICAgIH0KCmV2ZW50OiBwaW5nCmRhdGE6IHsidHlwZSI6ICJwaW5nIn0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiQnVpbGRpbmcifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiIHlvdXIgXCJwcmV2aWV3IGF1dG8tb3BlbiB0ZXN0XCIgd29ya2Zsb3cgbm93ISJ9ICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjAgICAgIH0KCmV2ZW50OiBtZXNzYWdlX2RlbHRhCmRhdGE6IHsidHlwZSI6Im1lc3NhZ2VfZGVsdGEiLCJkZWx0YSI6eyJzdG9wX3JlYXNvbiI6ImVuZF90dXJuIiwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsfSwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjoxMjY5LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjoxMjM2MCwib3V0cHV0X3Rva2VucyI6MTV9ICAgICAgICAgICAgICB9CgpldmVudDogbWVzc2FnZV9zdG9wCmRhdGE6IHsidHlwZSI6Im1lc3NhZ2Vfc3RvcCIgfQoK",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1775805992906-unknown-host-POST-_v1_messages-8a23f6c2.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "You generate a short descriptive title for a conversation",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["940"],
|
||||
"vary": ["Accept-Encoding"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=7.XiVbOXIR2WD60h.PEsXOTs0A5LcIFlDaLTiiq.Z8w-1775805979.34705-1.0.1.1-0ZPG4wCERfoe0f3NdOBzJiGKmhX0P2heXKMnYLvImio; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=946"],
|
||||
"request-id": ["req_011CZuhAphp8tASuZLU6522t"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-10T07:26:20Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["27000000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-10T07:26:19Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-10T07:26:20Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-10T07:26:20Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22500000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Fri, 10 Apr 2026 07:26:20 GMT"],
|
||||
"Content-Type": ["application/json"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"CF-RAY": ["9ea005cae94f761f-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "7.XiVbOXIR2WD60h.PEsXOTs0A5LcIFlDaLTiiq.Z8w-1775805979.34705-1.0.1.1-0ZPG4wCERfoe0f3NdOBzJiGKmhX0P2heXKMnYLvImio"
|
||||
},
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"type": "JSON",
|
||||
"json": {
|
||||
"model": "claude-sonnet-4-6",
|
||||
"id": "msg_01WFGshA9oCcsDMq9m6f2RHk",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Preview auto-open test workflow"
|
||||
}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"stop_details": null,
|
||||
"usage": {
|
||||
"input_tokens": 140,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation": {
|
||||
"ephemeral_5m_input_tokens": 0,
|
||||
"ephemeral_1h_input_tokens": 0
|
||||
},
|
||||
"output_tokens": 9,
|
||||
"service_tier": "standard",
|
||||
"inference_geo": "global"
|
||||
}
|
||||
},
|
||||
"rawBytes": "eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNC02IiwiaWQiOiJtc2dfMDFXRkdzaEE5b0Njc0RNcTltNmYyUkhrIiwidHlwZSI6Im1lc3NhZ2UiLCJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoiUHJldmlldyBhdXRvLW9wZW4gdGVzdCB3b3JrZmxvdyJ9XSwic3RvcF9yZWFzb24iOiJlbmRfdHVybiIsInN0b3Bfc2VxdWVuY2UiOm51bGwsInN0b3BfZGV0YWlscyI6bnVsbCwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjoxNDAsImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOjAsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjo5LCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0="
|
||||
}
|
||||
},
|
||||
"id": "1775805992921-unknown-host-POST-_v1_messages-18622610.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-62
File diff suppressed because one or more lines are too long
-62
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are an expert n8n workflow builder. You generate com",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["1216"],
|
||||
"vary": ["Accept-Encoding"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=5wzHd0OoEqvhiaD1IzQ.YvEEyDny.Ftq2KSxzE_VaAo-1775805982.6625264-1.0.1.1-Ra0ySdbHv.EezGVWuEOOEVf7PTDAhBc6tAI9n0FTMwc; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=1219"],
|
||||
"request-id": ["req_011CZuhB4yJjN1tDBne3Z4Cn"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-10T07:26:22Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["26975000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-10T07:26:22Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-10T07:26:22Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-10T07:26:22Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22475000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Fri, 10 Apr 2026 07:26:24 GMT"],
|
||||
"Content-Type": ["text/event-stream; charset=utf-8"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"Cache-Control": ["no-cache"],
|
||||
"CF-RAY": ["9ea005dfae3c34b9-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "5wzHd0OoEqvhiaD1IzQ.YvEEyDny.Ftq2KSxzE_VaAo-1775805982.6625264-1.0.1.1-Ra0ySdbHv.EezGVWuEOOEVf7PTDAhBc6tAI9n0FTMwc"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01UdEvLW6voE1Jy1KNSBcEiH\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":691,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":16176,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Done\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" — the workflow \\\"preview auto-open test\\\" is ready with a Manual Trigger connected to a Set node named exactly\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" \\\"preview auto-open test\\\".\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":691,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":16176,\"output_tokens\":35} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxVWRFdkxXNnZvRTFKeTFLTlNCY0VpSCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjY5MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTYxNzYsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxLCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0gICB9CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0YXJ0IiwiaW5kZXgiOjAsImNvbnRlbnRfYmxvY2siOnsidHlwZSI6InRleHQiLCJ0ZXh0IjoiIn0gICAgIH0KCmV2ZW50OiBwaW5nCmRhdGE6IHsidHlwZSI6ICJwaW5nIn0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiRG9uZSJ9fQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiIg4oCUIHRoZSB3b3JrZmxvdyBcInByZXZpZXcgYXV0by1vcGVuIHRlc3RcIiBpcyByZWFkeSB3aXRoIGEgTWFudWFsIFRyaWdnZXIgY29ubmVjdGVkIHRvIGEgU2V0IG5vZGUgbmFtZWQgZXhhY3RseSJ9ICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJ0ZXh0X2RlbHRhIiwidGV4dCI6IiBcInByZXZpZXcgYXV0by1vcGVuIHRlc3RcIi4ifX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjAgICAgICAgfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGx9LCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjY5MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTYxNzYsIm91dHB1dF90b2tlbnMiOjM1fSAgfQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiJtZXNzYWdlX3N0b3AiICAgICAgfQoK",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1775805992927-unknown-host-POST-_v1_messages-4d1c93f7.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["3410"],
|
||||
"vary": ["Accept-Encoding"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=yWsS2C4dRZNuHPmvx7_gFHBH96x100lgZMrEXHblC7c-1775805986.7840152-1.0.1.1-gn5xKLf_6FuPeOVlisqhzYJdeWigFNJLMzs2ou3SmTw; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=3412"],
|
||||
"request-id": ["req_011CZuhBNYjBXe9GCUEQxNg3"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-10T07:26:27Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["26976000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-10T07:26:27Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-10T07:26:27Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-10T07:26:27Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22476000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Fri, 10 Apr 2026 07:26:30 GMT"],
|
||||
"Content-Type": ["text/event-stream; charset=utf-8"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"Cache-Control": ["no-cache"],
|
||||
"CF-RAY": ["9ea005f968a0236c-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "yWsS2C4dRZNuHPmvx7_gFHBH96x100lgZMrEXHblC7c-1775805986.7840152-1.0.1.1-gn5xKLf_6FuPeOVlisqhzYJdeWigFNJLMzs2ou3SmTw"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01BBs3FuNkjeYpZhftyTVE32\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1367,\"cache_creation_input_tokens\":12360,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":12360,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"The\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" workflow **\\\"preview auto-open test\\\"** is ready! It has a Manual Trigger connected to a Set node named \\\"preview auto-open test\\\".\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" Let me know if you'd like to run it or make any changes.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1367,\"cache_creation_input_tokens\":12360,\"cache_read_input_tokens\":0,\"output_tokens\":51}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxQkJzM0Z1TmtqZVlwWmhmdHlUVkUzMiIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjEzNjcsImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyI6MTIzNjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjowLCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjoxMjM2MCwiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6MH0sIm91dHB1dF90b2tlbnMiOjEsInNlcnZpY2VfdGllciI6InN0YW5kYXJkIiwiaW5mZXJlbmNlX2dlbyI6Imdsb2JhbCJ9fX0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RhcnQiLCJpbmRleCI6MCwiY29udGVudF9ibG9jayI6eyJ0eXBlIjoidGV4dCIsInRleHQiOiIifSAgICAgICAgICB9CgpldmVudDogcGluZwpkYXRhOiB7InR5cGUiOiAicGluZyJ9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJ0ZXh0X2RlbHRhIiwidGV4dCI6IlRoZSJ9ICAgICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiIHdvcmtmbG93ICoqXCJwcmV2aWV3IGF1dG8tb3BlbiB0ZXN0XCIqKiBpcyByZWFkeSEgSXQgaGFzIGEgTWFudWFsIFRyaWdnZXIgY29ubmVjdGVkIHRvIGEgU2V0IG5vZGUgbmFtZWQgXCJwcmV2aWV3IGF1dG8tb3BlbiB0ZXN0XCIuIn0gIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiIExldCBtZSBrbm93IGlmIHlvdSdkIGxpa2UgdG8gcnVuIGl0IG9yIG1ha2UgYW55IGNoYW5nZXMuIn0gICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RvcCIsImluZGV4IjowfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGx9LCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjEzNjcsImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyI6MTIzNjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjowLCJvdXRwdXRfdG9rZW5zIjo1MX19CgpldmVudDogbWVzc2FnZV9zdG9wCmRhdGE6IHsidHlwZSI6Im1lc3NhZ2Vfc3RvcCIgICAgICAgICAgfQoK",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1775805992928-unknown-host-POST-_v1_messages-8a23f6c2.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-62
File diff suppressed because one or more lines are too long
-87
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"Generate a concise title (max 60 chars) summarizing what",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["1702"],
|
||||
"vary": ["Accept-Encoding"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=.y7zyvVlTGKLGGdRloXGaKTJv_TdOfSJ2weEqJR4I_U-1776069651.5247436-1.0.1.1-h9ID_FdX4qf6OiD00.uMhrHg02CPiSUVYBxHKBwSNRY; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=1704"],
|
||||
"request-id": ["req_011Ca1UHEUyFsWEatb2edCwg"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-13T08:40:53Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["27000000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-13T08:40:51Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-13T08:40:53Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-13T08:40:53Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22500000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Mon, 13 Apr 2026 08:40:53 GMT"],
|
||||
"Content-Type": ["application/json"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"CF-RAY": ["9eb92b1a0c38c760-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": ".y7zyvVlTGKLGGdRloXGaKTJv_TdOfSJ2weEqJR4I_U-1776069651.5247436-1.0.1.1-h9ID_FdX4qf6OiD00.uMhrHg02CPiSUVYBxHKBwSNRY"
|
||||
},
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"type": "JSON",
|
||||
"json": {
|
||||
"model": "claude-sonnet-4-6",
|
||||
"id": "msg_01DYRxS2jafFvPZ9D75bG7sV",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Build workflow with manual trigger and Set node"
|
||||
}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"stop_details": null,
|
||||
"usage": {
|
||||
"input_tokens": 116,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation": {
|
||||
"ephemeral_5m_input_tokens": 0,
|
||||
"ephemeral_1h_input_tokens": 0
|
||||
},
|
||||
"output_tokens": 11,
|
||||
"service_tier": "standard",
|
||||
"inference_geo": "global"
|
||||
}
|
||||
},
|
||||
"rawBytes": "eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNC02IiwiaWQiOiJtc2dfMDFEWVJ4UzJqYWZGdlBaOUQ3NWJHN3NWIiwidHlwZSI6Im1lc3NhZ2UiLCJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoiQnVpbGQgd29ya2Zsb3cgd2l0aCBtYW51YWwgdHJpZ2dlciBhbmQgU2V0IG5vZGUifV0sInN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGwsInVzYWdlIjp7ImlucHV0X3Rva2VucyI6MTE2LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjowLCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjowLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6MTEsInNlcnZpY2VfdGllciI6InN0YW5kYXJkIiwiaW5mZXJlbmNlX2dlbyI6Imdsb2JhbCJ9fQ=="
|
||||
}
|
||||
},
|
||||
"id": "1776069657469-unknown-host-POST-_v1_messages-18622610.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["855"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=H6DxUD_3DiH9aN0BdiHLJfyd8luvbbpKYDFburkIhUY-1776069650.0681944-1.0.1.1-yBz_K8Kcq6FyDiXig_riqAHR0orDs_vduSK828RYhIc; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=857"],
|
||||
"request-id": ["req_011Ca1UH8LdcffCz6ps67f8a"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-13T08:40:50Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["26977000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-13T08:40:50Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-13T08:40:50Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-13T08:40:50Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22477000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Mon, 13 Apr 2026 08:40:51 GMT"],
|
||||
"Content-Type": ["text/event-stream; charset=utf-8"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"Cache-Control": ["no-cache"],
|
||||
"CF-RAY": ["9eb92b10eff1e52a-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "H6DxUD_3DiH9aN0BdiHLJfyd8luvbbpKYDFburkIhUY-1776069650.0681944-1.0.1.1-yBz_K8Kcq6FyDiXig_riqAHR0orDs_vduSK828RYhIc"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01DYMREMwppb4GxwvdNG3YHp\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":581,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":12653,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}}\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Building\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" your \\\"preview auto-open test\\\" workflow now!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":581,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":12653,\"output_tokens\":15}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxRFlNUkVNd3BwYjRHeHd2ZE5HM1lIcCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjU4MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTI2NTMsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxLCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0gICAgICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RhcnQiLCJpbmRleCI6MCwiY29udGVudF9ibG9jayI6eyJ0eXBlIjoidGV4dCIsInRleHQiOiIifX0KCmV2ZW50OiBwaW5nCmRhdGE6IHsidHlwZSI6ICJwaW5nIn0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiQnVpbGRpbmcifSB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJ0ZXh0X2RlbHRhIiwidGV4dCI6IiB5b3VyIFwicHJldmlldyBhdXRvLW9wZW4gdGVzdFwiIHdvcmtmbG93IG5vdyEifSAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RvcCIsImluZGV4IjowICAgICAgICAgfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGx9LCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjU4MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTI2NTMsIm91dHB1dF90b2tlbnMiOjE1fX0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdG9wIiAgICAgICAgICAgICAgIH0KCg==",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1776069657469-unknown-host-POST-_v1_messages-8a23f6c2.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-62
File diff suppressed because one or more lines are too long
+105
File diff suppressed because one or more lines are too long
+111
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "REGEX",
|
||||
"regex": "[\\s\\S]*\\[\\{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde[\\s\\S]*\"type\"\\s*:\\s*\"tool_use\"[\\s\\S]{0,300}\"name\"\\s*:\\s*\"build-workflow-with-agent\"[\\s\\S]*"
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1735"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=DYqOUok9j.nx1z2zutTltCBWWgD9p9zWmuKIhUYNvFw-1777373830.902517-1.0.1.1-O5CWWeeDbxW89mFA0OkilOMg_bjMXFhfhmRk5xJfcYw; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1737"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaW3oubjFHbM7Q7gVAUcY"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-28T10:57:11Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26974000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-28T10:57:11Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-28T10:57:11Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-28T10:57:11Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22474000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Tue, 28 Apr 2026 10:57:12 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f358b6b2ae8b3dc-PRG"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "DYqOUok9j.nx1z2zutTltCBWWgD9p9zWmuKIhUYNvFw-1777373830.902517-1.0.1.1-O5CWWeeDbxW89mFA0OkilOMg_bjMXFhfhmRk5xJfcYw"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01DMUQAPF8VEKPCGYQBsPLNy\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":581,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":14091,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":34,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_01PAVHaGjT6pZnHjhznE4LXG\",\"name\":\"plan\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0}\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":581,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":14091,\"output_tokens\":34}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxRE1VUUFQRjhWRUtQQ0dZUUJzUExOeSIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjU4MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTQwOTEsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjozNCwic2VydmljZV90aWVyIjoic3RhbmRhcmQiLCJpbmZlcmVuY2VfZ2VvIjoiZ2xvYmFsIn19ICAgICAgICAgICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdGFydCIsImluZGV4IjowLCJjb250ZW50X2Jsb2NrIjp7InR5cGUiOiJ0b29sX3VzZSIsImlkIjoidG9vbHVfMDFQQVZIYUdqVDZwWm5Iamh6bkU0TFhHIiwibmFtZSI6InBsYW4iLCJpbnB1dCI6e30sImNhbGxlciI6eyJ0eXBlIjoiZGlyZWN0In19ICAgICAgICAgICAgfQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoiaW5wdXRfanNvbl9kZWx0YSIsInBhcnRpYWxfanNvbiI6IiJ9ICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RvcCIsImluZGV4IjowfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoidG9vbF91c2UiLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGx9LCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjU4MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTQwOTEsIm91dHB1dF90b2tlbnMiOjM0fX0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdG9wIiAgICAgICAgICB9Cgo=",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777373853818-unknown-host-POST-_v1_messages-4d84ab6b.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+111
File diff suppressed because one or more lines are too long
+111
File diff suppressed because one or more lines are too long
+111
File diff suppressed because one or more lines are too long
+111
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "REGEX",
|
||||
"regex": "[\\s\\S]*\\[\\{\"type\":\"text\",\"text\":\"You are the n8n Workflow Planner — you design solution a[\\s\\S]*\"type\"\\s*:\\s*\"tool_use\"[\\s\\S]{0,300}\"name\"\\s*:\\s*\"add-plan-item\"[\\s\\S]*"
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1036"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=.WrrWENlsGXrl1K_V5lE3BNWpdV4jTz8vMEx0UwJgmY-1777373843.1058683-1.0.1.1-tNrT4qO_xhzQBoyjl4zvb8_DJrK5zx_SiI5SaaNDUls; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1039"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaW3pofa2YZoMn3rZRJMD"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-28T10:57:23Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26983000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-28T10:57:23Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-28T10:57:23Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-28T10:57:23Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22483000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Tue, 28 Apr 2026 10:57:24 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f358bb76f0bf97a-PRG"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": ".WrrWENlsGXrl1K_V5lE3BNWpdV4jTz8vMEx0UwJgmY-1777373843.1058683-1.0.1.1-tNrT4qO_xhzQBoyjl4zvb8_DJrK5zx_SiI5SaaNDUls"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_01DEG7v92ZBBZz1GGVam9LHT\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":843,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":9471,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":36,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_011YnC8r3ywx2wnZF2AiuuiA\",\"name\":\"submit-plan\",\"input\":{},\"caller\":{\"type\":\"direct\"}} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":843,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":9471,\"output_tokens\":36} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxREVHN3Y5MlpCQlp6MUdHVmFtOUxIVCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjg0MywiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6OTQ3MSwiY2FjaGVfY3JlYXRpb24iOnsiZXBoZW1lcmFsXzVtX2lucHV0X3Rva2VucyI6MCwiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6MH0sIm91dHB1dF90b2tlbnMiOjM2LCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0gICAgICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0YXJ0IiwiaW5kZXgiOjAsImNvbnRlbnRfYmxvY2siOnsidHlwZSI6InRvb2xfdXNlIiwiaWQiOiJ0b29sdV8wMTFZbkM4cjN5d3gyd25aRjJBaXV1aUEiLCJuYW1lIjoic3VibWl0LXBsYW4iLCJpbnB1dCI6e30sImNhbGxlciI6eyJ0eXBlIjoiZGlyZWN0In19ICAgICAgICAgICAgIH0KCmV2ZW50OiBwaW5nCmRhdGE6IHsidHlwZSI6ICJwaW5nIn0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6ImlucHV0X2pzb25fZGVsdGEiLCJwYXJ0aWFsX2pzb24iOiIifSAgICAgICAgICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RvcApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0b3AiLCJpbmRleCI6MCAgICAgICAgICAgICAgIH0KCmV2ZW50OiBtZXNzYWdlX2RlbHRhCmRhdGE6IHsidHlwZSI6Im1lc3NhZ2VfZGVsdGEiLCJkZWx0YSI6eyJzdG9wX3JlYXNvbiI6InRvb2xfdXNlIiwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsfSwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjo4NDMsImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOjk0NzEsIm91dHB1dF90b2tlbnMiOjM2fSAgfQoKZXZlbnQ6IG1lc3NhZ2Vfc3RvcApkYXRhOiB7InR5cGUiOiJtZXNzYWdlX3N0b3AiICAgICAgICAgfQoK",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777373853820-unknown-host-POST-_v1_messages-e933f8d4.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "REGEX",
|
||||
"regex": "[\\s\\S]*\\[\\{\"type\":\"text\",\"text\":\"You are the n8n Workflow Planner — you design solution a[\\s\\S]*\"type\"\\s*:\\s*\"tool_use\"[\\s\\S]{0,300}\"name\"\\s*:\\s*\"submit-plan\"[\\s\\S]*"
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1127"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=8EljVH1DlY1CZBrXn.Hm6Y5kqwr4dY0A7TKxFxaHs40-1777373844.5735538-1.0.1.1-nHWxb9JR8M8qsWqObWqv7qR2_ulgcU3vc_fjHNsY4HE; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1129"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaW3puwrBkbsUGQsLwte4"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-28T10:57:24Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26983000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-28T10:57:24Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-28T10:57:24Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-28T10:57:24Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22483000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Tue, 28 Apr 2026 10:57:25 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f358bc09d58f99a-PRG"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "8EljVH1DlY1CZBrXn.Hm6Y5kqwr4dY0A7TKxFxaHs40-1777373844.5735538-1.0.1.1-nHWxb9JR8M8qsWqObWqv7qR2_ulgcU3vc_fjHNsY4HE"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_0173YQerYS4shheyYQzf2TaD\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":265,\"cache_creation_input_tokens\":227,\"cache_read_input_tokens\":9874,\"cache_creation\":{\"ephemeral_5m_input_tokens\":227,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Plan\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" approved.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":265,\"cache_creation_input_tokens\":227,\"cache_read_input_tokens\":9874,\"output_tokens\":6} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxNzNZUWVyWVM0c2hoZXlZUXpmMlRhRCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjI2NSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjoyMjcsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjo5ODc0LCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjoyMjcsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxLCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0gICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdGFydCIsImluZGV4IjowLCJjb250ZW50X2Jsb2NrIjp7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IiJ9ICAgICAgICAgfQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiJQbGFuIn0gICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJ0ZXh0X2RlbHRhIiwidGV4dCI6IiBhcHByb3ZlZC4ifSAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjAgICAgICAgICAgIH0KCmV2ZW50OiBtZXNzYWdlX2RlbHRhCmRhdGE6IHsidHlwZSI6Im1lc3NhZ2VfZGVsdGEiLCJkZWx0YSI6eyJzdG9wX3JlYXNvbiI6ImVuZF90dXJuIiwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsfSwidXNhZ2UiOnsiaW5wdXRfdG9rZW5zIjoyNjUsImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyI6MjI3LCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6OTg3NCwib3V0cHV0X3Rva2VucyI6Nn0gICAgICAgICAgIH0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdG9wIiAgICAgfQoK",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777373853821-unknown-host-POST-_v1_messages-7de835f7.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "REGEX",
|
||||
"regex": "[\\s\\S]*\\[\\{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde[\\s\\S]*\"type\"\\s*:\\s*\"tool_use\"[\\s\\S]{0,300}\"name\"\\s*:\\s*\"plan\"[\\s\\S]*"
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1094"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=hf4rP.Am87ek9ryWKVpeYO.Jcfo.9DmzG4E0QXm3suI-1777373846.0411108-1.0.1.1-1AobZoF915OysjHWEDJWq_7d4rX9Lxuc.cDuI291hxk; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1100"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaW3q2H6N3zDevayWiidz"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-28T10:57:26Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"26974000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-28T10:57:26Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-28T10:57:26Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-28T10:57:26Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22474000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Tue, 28 Apr 2026 10:57:27 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"text/event-stream; charset=utf-8"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"Cache-Control": [
|
||||
"no-cache"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f358bc9c96c8033-PRG"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "hf4rP.Am87ek9ryWKVpeYO.Jcfo.9DmzG4E0QXm3suI-1777373846.0411108-1.0.1.1-1AobZoF915OysjHWEDJWq_7d4rX9Lxuc.cDuI291hxk"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_017JN4E6h1BpesGnUFa4zUpV\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":640,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":14091,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"On\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" it!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":640,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":14091,\"output_tokens\":6} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxN0pONEU2aDFCcGVzR25VRmE0elVwViIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjY0MCwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTQwOTEsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxLCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0gICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RhcnQiLCJpbmRleCI6MCwiY29udGVudF9ibG9jayI6eyJ0eXBlIjoidGV4dCIsInRleHQiOiIifSAgICAgfQoKZXZlbnQ6IHBpbmcKZGF0YTogeyJ0eXBlIjogInBpbmcifQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiJPbiJ9ICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfZGVsdGEKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19kZWx0YSIsImluZGV4IjowLCJkZWx0YSI6eyJ0eXBlIjoidGV4dF9kZWx0YSIsInRleHQiOiIgaXQhIn0gIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjAgICB9CgpldmVudDogbWVzc2FnZV9kZWx0YQpkYXRhOiB7InR5cGUiOiJtZXNzYWdlX2RlbHRhIiwiZGVsdGEiOnsic3RvcF9yZWFzb24iOiJlbmRfdHVybiIsInN0b3Bfc2VxdWVuY2UiOm51bGwsInN0b3BfZGV0YWlscyI6bnVsbH0sInVzYWdlIjp7ImlucHV0X3Rva2VucyI6NjQwLCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjoxNDA5MSwib3V0cHV0X3Rva2VucyI6Nn0gICAgICB9CgpldmVudDogbWVzc2FnZV9zdG9wCmRhdGE6IHsidHlwZSI6Im1lc3NhZ2Vfc3RvcCIgICAgICAgICAgICAgfQoK",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1777373853821-unknown-host-POST-_v1_messages-b1d25736.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "REGEX",
|
||||
"regex": "[\\s\\S]*\\[\\{\"type\":\"text\",\"text\":\"You generate a short descriptive title for a conversatio[\\s\\S]*Generate a title for the following first message of a conversation\\. Do not answer the message — only produce the title\\.\\\\n[\\s\\S]*"
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": [
|
||||
"1167"
|
||||
],
|
||||
"vary": [
|
||||
"Accept-Encoding"
|
||||
],
|
||||
"strict-transport-security": [
|
||||
"max-age=31536000; includeSubDomains; preload"
|
||||
],
|
||||
"set-cookie": [
|
||||
"_cfuvid=.B5dR9MXMQVEr4z7XRcOmw7qo.IILfVWUPM6mHPCh9c-1777373847.596458-1.0.1.1-AlNJT50bl.0x4YJ63QiIbwUcYeb4CvVe_0K04mzQ4_w; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": [
|
||||
"x-originResponse;dur=1169"
|
||||
],
|
||||
"request-id": [
|
||||
"req_011CaW3q8wCKRAb3ZJcs5Vc5"
|
||||
],
|
||||
"cf-cache-status": [
|
||||
"DYNAMIC"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-reset": [
|
||||
"2026-04-28T10:57:28Z"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-remaining": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-tokens-limit": [
|
||||
"27000000"
|
||||
],
|
||||
"anthropic-ratelimit-requests-reset": [
|
||||
"2026-04-28T10:57:27Z"
|
||||
],
|
||||
"anthropic-ratelimit-requests-remaining": [
|
||||
"19998"
|
||||
],
|
||||
"anthropic-ratelimit-requests-limit": [
|
||||
"20000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-reset": [
|
||||
"2026-04-28T10:57:28Z"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-remaining": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-output-tokens-limit": [
|
||||
"4500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-reset": [
|
||||
"2026-04-28T10:57:28Z"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-remaining": [
|
||||
"22500000"
|
||||
],
|
||||
"anthropic-ratelimit-input-tokens-limit": [
|
||||
"22500000"
|
||||
],
|
||||
"X-Robots-Tag": [
|
||||
"none"
|
||||
],
|
||||
"Server": [
|
||||
"cloudflare"
|
||||
],
|
||||
"Date": [
|
||||
"Tue, 28 Apr 2026 10:57:28 GMT"
|
||||
],
|
||||
"Content-Type": [
|
||||
"application/json"
|
||||
],
|
||||
"Content-Security-Policy": [
|
||||
"default-src 'none'; frame-ancestors 'none'"
|
||||
],
|
||||
"CF-RAY": [
|
||||
"9f358bd37985a0f5-PRG"
|
||||
]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": ".B5dR9MXMQVEr4z7XRcOmw7qo.IILfVWUPM6mHPCh9c-1777373847.596458-1.0.1.1-AlNJT50bl.0x4YJ63QiIbwUcYeb4CvVe_0K04mzQ4_w"
|
||||
},
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"type": "JSON",
|
||||
"json": {
|
||||
"model": "claude-sonnet-4-6",
|
||||
"id": "msg_01LtW642qyg13YfiauXN73t8",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Simple workflow with manual trigger and set node"
|
||||
}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"stop_details": null,
|
||||
"usage": {
|
||||
"input_tokens": 401,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation": {
|
||||
"ephemeral_5m_input_tokens": 0,
|
||||
"ephemeral_1h_input_tokens": 0
|
||||
},
|
||||
"output_tokens": 11,
|
||||
"service_tier": "standard",
|
||||
"inference_geo": "global"
|
||||
}
|
||||
},
|
||||
"rawBytes": "eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNC02IiwiaWQiOiJtc2dfMDFMdFc2NDJxeWcxM1lmaWF1WE43M3Q4IiwidHlwZSI6Im1lc3NhZ2UiLCJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoiU2ltcGxlIHdvcmtmbG93IHdpdGggbWFudWFsIHRyaWdnZXIgYW5kIHNldCBub2RlIn1dLCJzdG9wX3JlYXNvbiI6ImVuZF90dXJuIiwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjQwMSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfY3JlYXRpb24iOnsiZXBoZW1lcmFsXzVtX2lucHV0X3Rva2VucyI6MCwiZXBoZW1lcmFsXzFoX2lucHV0X3Rva2VucyI6MH0sIm91dHB1dF90b2tlbnMiOjExLCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0="
|
||||
}
|
||||
},
|
||||
"id": "1777373853822-unknown-host-POST-_v1_messages-f134c753.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
+111
File diff suppressed because one or more lines are too long
+8
-4
@@ -1,4 +1,8 @@
|
||||
{"kind":"header","version":1,"testName":"recording","recordedAt":"2026-04-10T07:26:07.164Z"}
|
||||
{"kind":"tool-call","stepId":1,"agentRole":"orchestrator","toolName":"build-workflow-with-agent","input":{"task":"Build a simple workflow with a Manual Trigger node and a Set node called \"preview auto-open test\". The Set node doesn't need any specific fields — just the node named correctly.","conversationContext":"User wants a simple test workflow with a Manual Trigger and a Set node named \"preview auto-open test\"."},"output":{"result":"Workflow build started (task: build-o6BfZ7pF). Reply with one short sentence — e.g. name what's being built. Do NOT summarize the plan or list details.","taskId":"build-o6BfZ7pF"}}
|
||||
{"kind":"tool-call","stepId":2,"agentRole":"workflow-builder","toolName":"build-workflow","input":{"code":"\nimport { workflow, node, trigger } from '@n8n/workflow-sdk';\n\nconst manualTrigger = trigger({\n type: 'n8n-nodes-base.manualTrigger',\n version: 1,\n config: { name: 'Manual Trigger' }\n});\n\nconst previewNode = node({\n type: 'n8n-nodes-base.set',\n version: 3.4,\n config: {\n name: 'preview auto-open test',\n parameters: {\n assignments: { assignments: [] },\n options: {}\n }\n }\n});\n\nexport default workflow('preview-auto-open-test', 'preview auto-open test')\n .add(manualTrigger)\n .to(previewNode);\n","name":"preview auto-open test"},"output":{"success":true,"workflowId":"ecdTLGco3Mr0yobN"}}
|
||||
{"kind":"header","version":1,"testName":"recording","recordedAt":"2026-04-10T07:26:24.869Z"}
|
||||
{"kind":"header","version":1,"testName":"recording","recordedAt":"2026-04-28T10:57:06.568Z"}
|
||||
{"kind":"tool-call","stepId":1,"agentRole":"orchestrator","toolName":"build-workflow-with-agent","input":{"task":"Build a simple workflow with a Manual Trigger node connected to a Set node. Name the workflow \"preview auto-open test\". The Set node can have a simple example field, e.g. a field named \"message\" with value \"Hello World\".","bypassPlan":true,"reason":"New simple workflow with manual trigger and set node as requested."},"output":{"result":"Error: `bypassPlan: true` is for edits to an EXISTING workflow and requires a `workflowId`. New workflow builds must go through `plan` so an orchestrator-run verification checkpoint is scheduled. Call `plan` with a `build-workflow` task instead.","taskId":""}}
|
||||
{"kind":"tool-call","stepId":2,"agentRole":"planner","toolName":"add-plan-item","input":{"summary":"A single simple workflow named \"preview auto-open test\" with a manual trigger and a Set node.","assumptions":["Set node will have a sample field (e.g. a string field) to produce visible output on execution."],"item":{"id":"wf-preview-auto-open","name":"preview auto-open test","purpose":"A minimal workflow with a manual trigger and a Set node for testing the preview auto-open behavior. When executed manually, it passes through the trigger and the Set node produces output data for inspection.","integrations":[],"triggerDescription":"Manual trigger","dependsOn":[],"kind":"workflow"}},"output":{"result":"Added: Build 'preview auto-open test' workflow (1 item total)"}}
|
||||
{"kind":"tool-call","stepId":3,"agentRole":"planner","toolName":"add-plan-item","input":{"item":{"id":"chk-preview-auto-open","title":"Verify 'preview auto-open test' workflow runs successfully","instructions":"Use verify-built-workflow with the work item ID from the build outcome. Confirm the workflow runs without errors and the Set node produces at least one output item.","dependsOn":["wf-preview-auto-open"],"kind":"checkpoint"}},"output":{"result":"Added: Verify 'preview auto-open test' workflow runs successfully (2 items total)"}}
|
||||
{"kind":"tool-call","stepId":4,"agentRole":"planner","toolName":"submit-plan","input":{},"output":{"approved":false,"feedback":"Awaiting approval"}}
|
||||
{"kind":"tool-resume","stepId":5,"agentRole":"planner","toolName":"submit-plan","input":{},"output":{"approved":true},"resumeData":{"approved":true}}
|
||||
{"kind":"tool-call","stepId":6,"agentRole":"orchestrator","toolName":"plan","input":{},"output":{"result":"Plan approved and 2 tasks dispatched."}}
|
||||
{"kind":"tool-call","stepId":7,"agentRole":"workflow-builder","toolName":"build-workflow","input":{"code":"\nimport { workflow, node, trigger, newCredential, expr } from '@n8n/workflow-sdk';\n\nconst manualTrigger = trigger({\n type: 'n8n-nodes-base.manualTrigger',\n version: 1,\n config: { name: 'Manual Trigger' }\n});\n\nconst setNode = node({\n type: 'n8n-nodes-base.set',\n version: 3.4,\n config: {\n name: 'Set Sample Data',\n parameters: {\n assignments: {\n assignments: [\n {\n id: 'field-1',\n name: 'message',\n value: 'Hello from Set node!',\n type: 'string'\n },\n {\n id: 'field-2',\n name: 'status',\n value: 'success',\n type: 'string'\n },\n {\n id: 'field-3',\n name: 'count',\n value: 42,\n type: 'number'\n }\n ]\n }\n }\n }\n});\n\nexport default workflow('manual-set-preview', 'Manual Trigger + Set Node Preview')\n .add(manualTrigger)\n .to(setNode);\n","name":"Manual Trigger + Set Node Preview"},"output":{"success":true,"workflowId":"c2ssonE5bINFzhVN"}}
|
||||
|
||||
-62
File diff suppressed because one or more lines are too long
-61
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["1966"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=CW1xhCwVoEE2JFj1RXVUjlfsRlQLCdu.8Kydo2uT3zQ-1775805987.4618607-1.0.1.1-kx80xMz1ZvyKw2zCh6HfitN261bM3mzGerinpZeWGkc; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=1968"],
|
||||
"request-id": ["req_011CZuhBRRs3UTthcWj1vn7V"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-10T07:26:27Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["26951000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-10T07:26:27Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19995"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-10T07:26:27Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-10T07:26:27Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22451000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Fri, 10 Apr 2026 07:26:29 GMT"],
|
||||
"Content-Type": ["text/event-stream; charset=utf-8"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"Cache-Control": ["no-cache"],
|
||||
"CF-RAY": ["9ea005fdaa4de504-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "CW1xhCwVoEE2JFj1RXVUjlfsRlQLCdu.8Kydo2uT3zQ-1775805987.4618607-1.0.1.1-kx80xMz1ZvyKw2zCh6HfitN261bM3mzGerinpZeWGkc"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_016i5AqiWjDuHWMTU2F1cvcD\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1096,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":12360,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Building\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" your \\\"close preview test\\\" workflow now!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1096,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":12360,\"output_tokens\":13} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxNmk1QXFpV2pEdUhXTVRVMkYxY3ZjRCIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjEwOTYsImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOjEyMzYwLCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjowLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6MSwic2VydmljZV90aWVyIjoic3RhbmRhcmQiLCJpbmZlcmVuY2VfZ2VvIjoiZ2xvYmFsIn19IH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0YXJ0CmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RhcnQiLCJpbmRleCI6MCwiY29udGVudF9ibG9jayI6eyJ0eXBlIjoidGV4dCIsInRleHQiOiIifSAgICAgICAgIH0KCmV2ZW50OiBwaW5nCmRhdGE6IHsidHlwZSI6ICJwaW5nIn0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiQnVpbGRpbmcifSAgICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiIHlvdXIgXCJjbG9zZSBwcmV2aWV3IHRlc3RcIiB3b3JrZmxvdyBub3chIn0gICAgICAgICAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RvcCIsImluZGV4IjowICAgfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGx9LCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjEwOTYsImNhY2hlX2NyZWF0aW9uX2lucHV0X3Rva2VucyI6MCwiY2FjaGVfcmVhZF9pbnB1dF90b2tlbnMiOjEyMzYwLCJvdXRwdXRfdG9rZW5zIjoxM30gICAgIH0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdG9wIiB9Cgo=",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1775805997995-unknown-host-POST-_v1_messages-8a23f6c2.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "You generate a short descriptive title for a conversation",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["977"],
|
||||
"vary": ["Accept-Encoding"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=MgDbgjY.DRA1Fzn6kTiLY8ARog1k5cqkVunM5vdEuBI-1775805991.1848216-1.0.1.1-22QhNXvGKUOlPuwzykaoNmNZTguyJyYJ_41uXWqhSK0; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=978"],
|
||||
"request-id": ["req_011CZuhBhJM2AnW6LKZ5Efws"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-10T07:26:31Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["27000000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-10T07:26:31Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-10T07:26:32Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-10T07:26:31Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22500000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Fri, 10 Apr 2026 07:26:32 GMT"],
|
||||
"Content-Type": ["application/json"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"CF-RAY": ["9ea00614ed6de567-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "MgDbgjY.DRA1Fzn6kTiLY8ARog1k5cqkVunM5vdEuBI-1775805991.1848216-1.0.1.1-22QhNXvGKUOlPuwzykaoNmNZTguyJyYJ_41uXWqhSK0"
|
||||
},
|
||||
"body": {
|
||||
"contentType": "application/json",
|
||||
"type": "JSON",
|
||||
"json": {
|
||||
"model": "claude-sonnet-4-6",
|
||||
"id": "msg_01PN8refCFuJaXAeyLwRxHgR",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Close preview test workflow with manual trigger"
|
||||
}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"stop_details": null,
|
||||
"usage": {
|
||||
"input_tokens": 136,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation": {
|
||||
"ephemeral_5m_input_tokens": 0,
|
||||
"ephemeral_1h_input_tokens": 0
|
||||
},
|
||||
"output_tokens": 10,
|
||||
"service_tier": "standard",
|
||||
"inference_geo": "global"
|
||||
}
|
||||
},
|
||||
"rawBytes": "eyJtb2RlbCI6ImNsYXVkZS1zb25uZXQtNC02IiwiaWQiOiJtc2dfMDFQTjhyZWZDRnVKYVhBZXlMd1J4SGdSIiwidHlwZSI6Im1lc3NhZ2UiLCJyb2xlIjoiYXNzaXN0YW50IiwiY29udGVudCI6W3sidHlwZSI6InRleHQiLCJ0ZXh0IjoiQ2xvc2UgcHJldmlldyB0ZXN0IHdvcmtmbG93IHdpdGggbWFudWFsIHRyaWdnZXIifV0sInN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGwsInVzYWdlIjp7ImlucHV0X3Rva2VucyI6MTM2LCJjYWNoZV9jcmVhdGlvbl9pbnB1dF90b2tlbnMiOjAsImNhY2hlX3JlYWRfaW5wdXRfdG9rZW5zIjowLCJjYWNoZV9jcmVhdGlvbiI6eyJlcGhlbWVyYWxfNW1faW5wdXRfdG9rZW5zIjowLCJlcGhlbWVyYWxfMWhfaW5wdXRfdG9rZW5zIjowfSwib3V0cHV0X3Rva2VucyI6MTAsInNlcnZpY2VfdGllciI6InN0YW5kYXJkIiwiaW5mZXJlbmNlX2dlbyI6Imdsb2JhbCJ9fQ=="
|
||||
}
|
||||
},
|
||||
"id": "1775805998005-unknown-host-POST-_v1_messages-18622610.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-62
File diff suppressed because one or more lines are too long
-62
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are an expert n8n workflow builder. You generate com",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["1053"],
|
||||
"vary": ["Accept-Encoding"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=dw9P_uK.qzY1duqeoqSO3tOze9Ow2DkxcimOq.L6ROA-1775805993.5031893-1.0.1.1-LU9wilQh25yEsVs9GaMj2izjYsVhsKFFL9Ehy.wBguY; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=1056"],
|
||||
"request-id": ["req_011CZuhBsDtxa5c9VA3qe7py"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-10T07:26:33Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["26975000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-10T07:26:33Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-10T07:26:33Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-10T07:26:33Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22475000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Fri, 10 Apr 2026 07:26:34 GMT"],
|
||||
"Content-Type": ["text/event-stream; charset=utf-8"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"Cache-Control": ["no-cache"],
|
||||
"CF-RAY": ["9ea006236f50b9de-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "dw9P_uK.qzY1duqeoqSO3tOze9Ow2DkxcimOq.L6ROA-1775805993.5031893-1.0.1.1-LU9wilQh25yEsVs9GaMj2izjYsVhsKFFL9Ehy.wBguY"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_013pcqKCFc54F8hxhAMgc8Zr\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":671,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":16176,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}}}\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Done\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" — the \\\"close preview test\\\" workflow is ready with a Manual Trigger connected to an\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" empty Set node.\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":671,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":16176,\"output_tokens\":26}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxM3BjcUtDRmM1NEY4aHhoQU1nYzhaciIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjY3MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTYxNzYsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxLCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX19CgpldmVudDogY29udGVudF9ibG9ja19zdGFydApkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX3N0YXJ0IiwiaW5kZXgiOjAsImNvbnRlbnRfYmxvY2siOnsidHlwZSI6InRleHQiLCJ0ZXh0IjoiIn0gICAgICB9CgpldmVudDogcGluZwpkYXRhOiB7InR5cGUiOiAicGluZyJ9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJ0ZXh0X2RlbHRhIiwidGV4dCI6IkRvbmUifSAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJ0ZXh0X2RlbHRhIiwidGV4dCI6IiDigJQgdGhlIFwiY2xvc2UgcHJldmlldyB0ZXN0XCIgd29ya2Zsb3cgaXMgcmVhZHkgd2l0aCBhIE1hbnVhbCBUcmlnZ2VyIGNvbm5lY3RlZCB0byBhbiJ9ICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJ0ZXh0X2RlbHRhIiwidGV4dCI6IiBlbXB0eSBTZXQgbm9kZS4ifSAgICAgICAgICAgIH0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX3N0b3AKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdG9wIiwiaW5kZXgiOjAgICAgICAgfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGx9LCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjY3MSwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTYxNzYsIm91dHB1dF90b2tlbnMiOjI2fX0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdG9wIiAgICAgfQoK",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1775805998012-unknown-host-POST-_v1_messages-4d1c93f7.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
-62
File diff suppressed because one or more lines are too long
-62
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"httpRequest": {
|
||||
"method": "POST",
|
||||
"path": "/v1/messages",
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "[{\"type\":\"text\",\"text\":\"You are the n8n Instance Agent — an AI assistant embedde",
|
||||
"subString": true
|
||||
}
|
||||
},
|
||||
"httpResponse": {
|
||||
"statusCode": 200,
|
||||
"reasonPhrase": "OK",
|
||||
"headers": {
|
||||
"x-envoy-upstream-service-time": ["1633"],
|
||||
"vary": ["Accept-Encoding"],
|
||||
"strict-transport-security": ["max-age=31536000; includeSubDomains; preload"],
|
||||
"set-cookie": [
|
||||
"_cfuvid=JmTf2n7v89OTEF1Qf8X5IyOiUvYy_1ftE5q5gvxt_GU-1776069649.0433874-1.0.1.1-LK5DCiXVqldXkR2Ti3KiW5CPkffAsYMzGLqfMzKBLpE; HttpOnly; SameSite=None; Secure; Path=/; Domain=api.anthropic.com"
|
||||
],
|
||||
"server-timing": ["x-originResponse;dur=1637"],
|
||||
"request-id": ["req_011Ca1UH4FpSH3RDYgmK4FwG"],
|
||||
"cf-cache-status": ["DYNAMIC"],
|
||||
"anthropic-ratelimit-tokens-reset": ["2026-04-13T08:40:49Z"],
|
||||
"anthropic-ratelimit-tokens-remaining": ["26977000"],
|
||||
"anthropic-ratelimit-tokens-limit": ["27000000"],
|
||||
"anthropic-ratelimit-requests-reset": ["2026-04-13T08:40:49Z"],
|
||||
"anthropic-ratelimit-requests-remaining": ["19998"],
|
||||
"anthropic-ratelimit-requests-limit": ["20000"],
|
||||
"anthropic-ratelimit-output-tokens-reset": ["2026-04-13T08:40:49Z"],
|
||||
"anthropic-ratelimit-output-tokens-remaining": ["4500000"],
|
||||
"anthropic-ratelimit-output-tokens-limit": ["4500000"],
|
||||
"anthropic-ratelimit-input-tokens-reset": ["2026-04-13T08:40:49Z"],
|
||||
"anthropic-ratelimit-input-tokens-remaining": ["22477000"],
|
||||
"anthropic-ratelimit-input-tokens-limit": ["22500000"],
|
||||
"X-Robots-Tag": ["none"],
|
||||
"Server": ["cloudflare"],
|
||||
"Date": ["Mon, 13 Apr 2026 08:40:50 GMT"],
|
||||
"Content-Type": ["text/event-stream; charset=utf-8"],
|
||||
"Content-Security-Policy": ["default-src 'none'; frame-ancestors 'none'"],
|
||||
"Cache-Control": ["no-cache"],
|
||||
"CF-RAY": ["9eb92b0a8f839227-TXL"]
|
||||
},
|
||||
"cookies": {
|
||||
"_cfuvid": "JmTf2n7v89OTEF1Qf8X5IyOiUvYy_1ftE5q5gvxt_GU-1776069649.0433874-1.0.1.1-LK5DCiXVqldXkR2Ti3KiW5CPkffAsYMzGLqfMzKBLpE"
|
||||
},
|
||||
"body": {
|
||||
"type": "STRING",
|
||||
"string": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-6\",\"id\":\"msg_019RHisozftPsJ69iqLmAERb\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":574,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":12653,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":1,\"service_tier\":\"standard\",\"inference_geo\":\"global\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Building\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" your \\\"close preview test\\\" workflow now!\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":574,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":12653,\"output_tokens\":13}}\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n",
|
||||
"rawBytes": "ZXZlbnQ6IG1lc3NhZ2Vfc3RhcnQKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdGFydCIsIm1lc3NhZ2UiOnsibW9kZWwiOiJjbGF1ZGUtc29ubmV0LTQtNiIsImlkIjoibXNnXzAxOVJIaXNvemZ0UHNKNjlpcUxtQUVSYiIsInR5cGUiOiJtZXNzYWdlIiwicm9sZSI6ImFzc2lzdGFudCIsImNvbnRlbnQiOltdLCJzdG9wX3JlYXNvbiI6bnVsbCwic3RvcF9zZXF1ZW5jZSI6bnVsbCwic3RvcF9kZXRhaWxzIjpudWxsLCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjU3NCwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTI2NTMsImNhY2hlX2NyZWF0aW9uIjp7ImVwaGVtZXJhbF81bV9pbnB1dF90b2tlbnMiOjAsImVwaGVtZXJhbF8xaF9pbnB1dF90b2tlbnMiOjB9LCJvdXRwdXRfdG9rZW5zIjoxLCJzZXJ2aWNlX3RpZXIiOiJzdGFuZGFyZCIsImluZmVyZW5jZV9nZW8iOiJnbG9iYWwifX0gICAgICAgICAgICAgfQoKZXZlbnQ6IGNvbnRlbnRfYmxvY2tfc3RhcnQKZGF0YTogeyJ0eXBlIjoiY29udGVudF9ibG9ja19zdGFydCIsImluZGV4IjowLCJjb250ZW50X2Jsb2NrIjp7InR5cGUiOiJ0ZXh0IiwidGV4dCI6IiJ9ICAgICAgICAgICAgIH0KCmV2ZW50OiBwaW5nCmRhdGE6IHsidHlwZSI6ICJwaW5nIn0KCmV2ZW50OiBjb250ZW50X2Jsb2NrX2RlbHRhCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfZGVsdGEiLCJpbmRleCI6MCwiZGVsdGEiOnsidHlwZSI6InRleHRfZGVsdGEiLCJ0ZXh0IjoiQnVpbGRpbmcifSAgICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19kZWx0YQpkYXRhOiB7InR5cGUiOiJjb250ZW50X2Jsb2NrX2RlbHRhIiwiaW5kZXgiOjAsImRlbHRhIjp7InR5cGUiOiJ0ZXh0X2RlbHRhIiwidGV4dCI6IiB5b3VyIFwiY2xvc2UgcHJldmlldyB0ZXN0XCIgd29ya2Zsb3cgbm93ISJ9ICAgICB9CgpldmVudDogY29udGVudF9ibG9ja19zdG9wCmRhdGE6IHsidHlwZSI6ImNvbnRlbnRfYmxvY2tfc3RvcCIsImluZGV4IjowICAgfQoKZXZlbnQ6IG1lc3NhZ2VfZGVsdGEKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9kZWx0YSIsImRlbHRhIjp7InN0b3BfcmVhc29uIjoiZW5kX3R1cm4iLCJzdG9wX3NlcXVlbmNlIjpudWxsLCJzdG9wX2RldGFpbHMiOm51bGx9LCJ1c2FnZSI6eyJpbnB1dF90b2tlbnMiOjU3NCwiY2FjaGVfY3JlYXRpb25faW5wdXRfdG9rZW5zIjowLCJjYWNoZV9yZWFkX2lucHV0X3Rva2VucyI6MTI2NTMsIm91dHB1dF90b2tlbnMiOjEzfX0KCmV2ZW50OiBtZXNzYWdlX3N0b3AKZGF0YTogeyJ0eXBlIjoibWVzc2FnZV9zdG9wIiAgICAgICAgICAgICB9Cgo=",
|
||||
"contentType": "text/event-stream; charset=utf-8"
|
||||
}
|
||||
},
|
||||
"id": "1776069656718-unknown-host-POST-_v1_messages-8a23f6c2.json",
|
||||
"priority": 0,
|
||||
"timeToLive": {
|
||||
"unlimited": true
|
||||
},
|
||||
"times": {
|
||||
"unlimited": true
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user