@nylorun/runtime 0.10.0-beta → 0.12.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +86 -0
- package/README.md +53 -51
- package/dist/adapters/execution/restate.d.ts +130 -0
- package/dist/adapters/execution/restate.js +442 -0
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/adapters/streams/s2.d.ts +18 -0
- package/dist/adapters/streams/s2.js +361 -0
- package/dist/core/executors.d.ts +4 -1
- package/dist/core/executors.js +20 -1
- package/dist/core/flow-host.d.ts +93 -132
- package/dist/core/flow-host.js +282 -547
- package/dist/core/sandbox-routes.d.ts +13 -4
- package/dist/core/sandbox-routes.js +14 -12
- package/dist/execution/memory.d.ts +54 -0
- package/dist/execution/memory.js +217 -0
- package/dist/execution/types.d.ts +125 -0
- package/dist/execution/types.js +52 -0
- package/dist/host/config-for.d.ts +2 -1
- package/dist/host/config-for.js +3 -21
- package/dist/host/config.d.ts +0 -8
- package/dist/host/create-host.d.ts +36 -1
- package/dist/host/create-host.js +51 -24
- package/dist/host/execution.d.ts +61 -0
- package/dist/host/execution.js +69 -0
- package/dist/host/http.d.ts +4 -0
- package/dist/host/http.js +6 -0
- package/dist/host/index.d.ts +3 -1
- package/dist/host/index.js +2 -0
- package/dist/host/main.js +106 -47
- package/dist/host/stack-config.d.ts +72 -0
- package/dist/host/stack-config.js +193 -0
- package/dist/index.d.ts +1 -1
- package/dist/infra/database.d.ts +16 -0
- package/dist/infra/database.js +31 -0
- package/dist/infra/execution.d.ts +39 -0
- package/dist/infra/execution.js +68 -0
- package/dist/infra/index.d.ts +42 -0
- package/dist/infra/index.js +56 -0
- package/dist/infra/readiness.d.ts +33 -0
- package/dist/infra/readiness.js +53 -0
- package/dist/infra/streams.d.ts +17 -0
- package/dist/infra/streams.js +33 -0
- package/dist/infra/workers.d.ts +39 -0
- package/dist/infra/workers.js +31 -0
- package/dist/model/pi-model.d.ts +2 -2
- package/dist/model/pi-model.js +5 -5
- package/dist/sandbox/manager.d.ts +26 -8
- package/dist/sandbox/manager.js +95 -47
- package/dist/sandbox/select.d.ts +1 -1
- package/dist/sandbox/select.js +4 -5
- package/dist/sandbox/types.d.ts +2 -2
- package/dist/store/canonical.d.ts +5 -0
- package/dist/store/canonical.js +17 -0
- package/dist/store/cursor.d.ts +9 -0
- package/dist/store/cursor.js +18 -0
- package/dist/store/memory.d.ts +57 -0
- package/dist/store/memory.js +645 -0
- package/dist/store/ownership.d.ts +19 -0
- package/dist/store/ownership.js +33 -0
- package/dist/store/postgres/connect.d.ts +25 -0
- package/dist/store/postgres/connect.js +25 -0
- package/dist/store/postgres/locking.d.ts +16 -0
- package/dist/store/postgres/locking.js +22 -0
- package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
- package/dist/store/postgres/migrations/001_initial.js +196 -0
- package/dist/store/postgres/migrations/002_session_owner.d.ts +6 -0
- package/dist/store/postgres/migrations/002_session_owner.js +13 -0
- package/dist/store/postgres/migrations/index.d.ts +49 -0
- package/dist/store/postgres/migrations/index.js +76 -0
- package/dist/store/postgres/names.d.ts +11 -0
- package/dist/store/postgres/names.js +42 -0
- package/dist/store/postgres/store.d.ts +11 -0
- package/dist/store/postgres/store.js +797 -0
- package/dist/store/postgres/tenants.d.ts +90 -0
- package/dist/store/postgres/tenants.js +214 -0
- package/dist/store/types.d.ts +442 -0
- package/dist/store/types.js +10 -0
- package/dist/streams/basin.d.ts +23 -0
- package/dist/streams/basin.js +51 -0
- package/dist/streams/memory.d.ts +25 -0
- package/dist/streams/memory.js +124 -0
- package/dist/streams/relay.d.ts +37 -0
- package/dist/streams/relay.js +254 -0
- package/dist/streams/types.d.ts +115 -0
- package/dist/streams/types.js +86 -0
- package/dist/tenant/actions.d.ts +153 -0
- package/dist/tenant/actions.js +191 -0
- package/dist/tenant/advance.d.ts +15 -0
- package/dist/tenant/advance.js +439 -0
- package/dist/tenant/auth.d.ts +32 -0
- package/dist/tenant/auth.js +169 -0
- package/dist/tenant/commands.d.ts +3 -0
- package/dist/tenant/commands.js +393 -0
- package/dist/tenant/context.d.ts +147 -0
- package/dist/tenant/context.js +48 -0
- package/dist/tenant/effects.d.ts +20 -0
- package/dist/tenant/effects.js +564 -0
- package/dist/tenant/envelope.d.ts +0 -3
- package/dist/tenant/envelope.js +2 -34
- package/dist/tenant/ephemeral.d.ts +15 -4
- package/dist/tenant/ephemeral.js +106 -38
- package/dist/tenant/http.d.ts +38 -0
- package/dist/tenant/http.js +53 -0
- package/dist/tenant/live.d.ts +92 -0
- package/dist/tenant/live.js +290 -0
- package/dist/tenant/model-setting.d.ts +16 -0
- package/dist/tenant/model-setting.js +12 -0
- package/dist/tenant/module.d.ts +29 -8
- package/dist/tenant/module.js +165 -158
- package/dist/tenant/paths.d.ts +12 -8
- package/dist/tenant/paths.js +38 -12
- package/dist/tenant/principals.d.ts +12 -13
- package/dist/tenant/principals.js +50 -38
- package/dist/tenant/quarantine-error.d.ts +1 -1
- package/dist/tenant/quarantine-error.js +2 -8
- package/dist/tenant/quarantine.d.ts +0 -8
- package/dist/tenant/quarantine.js +8 -30
- package/dist/tenant/reset.d.ts +7 -6
- package/dist/tenant/reset.js +7 -51
- package/dist/tenant/routes-tenant.d.ts +12 -0
- package/dist/tenant/routes-tenant.js +137 -0
- package/dist/tenant/routes.d.ts +9 -0
- package/dist/tenant/routes.js +151 -0
- package/dist/tenant/runtime.d.ts +59 -58
- package/dist/tenant/runtime.js +243 -2201
- package/dist/tenant/scheduler.d.ts +25 -0
- package/dist/tenant/scheduler.js +84 -0
- package/dist/tenant/session.d.ts +60 -0
- package/dist/tenant/session.js +94 -0
- package/dist/tenant/sessions.d.ts +40 -0
- package/dist/tenant/sessions.js +163 -0
- package/dist/tenant/status.d.ts +13 -4
- package/dist/tenant/status.js +76 -41
- package/dist/tenant/store-memory.d.ts +9 -12
- package/dist/tenant/store-memory.js +79 -68
- package/dist/tenant/store-pg.d.ts +15 -0
- package/dist/tenant/store-pg.js +140 -0
- package/dist/tenant/streams.d.ts +126 -0
- package/dist/tenant/streams.js +419 -0
- package/dist/tenant/sweep.d.ts +15 -0
- package/dist/tenant/sweep.js +122 -0
- package/dist/tenant/transcript.d.ts +104 -0
- package/dist/tenant/transcript.js +58 -0
- package/dist/tenant/types.d.ts +101 -15
- package/dist/tenant/types.js +23 -1
- package/dist/tenant/worker.d.ts +84 -0
- package/dist/tenant/worker.js +189 -0
- package/dist/vault/service.d.ts +44 -31
- package/dist/vault/service.js +454 -360
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +15 -14
- package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
- package/dist/adapters/sandbox/microsandbox.js +0 -238
- package/dist/core/store.d.ts +0 -32
- package/dist/core/store.js +0 -143
- package/dist/launcher/commands.d.ts +0 -24
- package/dist/launcher/commands.js +0 -260
- package/dist/launcher/ensure-config.d.ts +0 -16
- package/dist/launcher/ensure-config.js +0 -79
- package/dist/launcher/errors.d.ts +0 -12
- package/dist/launcher/errors.js +0 -22
- package/dist/launcher/host-config.d.ts +0 -17
- package/dist/launcher/host-config.js +0 -64
- package/dist/launcher/host-state.d.ts +0 -17
- package/dist/launcher/host-state.js +0 -61
- package/dist/launcher/lifecycle.d.ts +0 -66
- package/dist/launcher/lifecycle.js +0 -407
- package/dist/launcher/locks.d.ts +0 -26
- package/dist/launcher/locks.js +0 -117
- package/dist/launcher/logs.d.ts +0 -18
- package/dist/launcher/logs.js +0 -117
- package/dist/launcher/main.d.ts +0 -2
- package/dist/launcher/main.js +0 -66
- package/dist/launcher/output.d.ts +0 -11
- package/dist/launcher/output.js +0 -68
- package/dist/launcher/paths.d.ts +0 -20
- package/dist/launcher/paths.js +0 -36
- package/dist/launcher/probe.d.ts +0 -23
- package/dist/launcher/probe.js +0 -72
- package/dist/launcher/protocol.d.ts +0 -62
- package/dist/launcher/protocol.js +0 -1
- package/dist/launcher/schema-guard.d.ts +0 -6
- package/dist/launcher/schema-guard.js +0 -36
- package/dist/launcher/spawn.d.ts +0 -22
- package/dist/launcher/spawn.js +0 -85
- package/dist/launcher/status.d.ts +0 -6
- package/dist/launcher/status.js +0 -65
- package/dist/tenant/migration.d.ts +0 -16
- package/dist/tenant/migration.js +0 -116
- package/dist/tenant/schema.d.ts +0 -23
- package/dist/tenant/schema.js +0 -151
- package/dist/tenant/store-fs.d.ts +0 -16
- package/dist/tenant/store-fs.js +0 -205
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { SCOPES_HEADER, SUBJECT_HEADER, } from "@nylorun/core/compatibility";
|
|
2
|
+
import { parseSubjectHeaders, } from "@nylorun/core/contracts";
|
|
3
|
+
import { hashToken } from "../core/executors.js";
|
|
4
|
+
import { fail, failOpaque } from "./http.js";
|
|
5
|
+
const SUBJECT_INVALID = { code: "subject_invalid" };
|
|
6
|
+
/** One header's value, or `undefined`; a header sent twice is refused. */
|
|
7
|
+
function singleHeader(request, name) {
|
|
8
|
+
const values = request.headersDistinct?.[name.toLowerCase()];
|
|
9
|
+
if (!values) {
|
|
10
|
+
const raw = request.headers[name.toLowerCase()];
|
|
11
|
+
return Array.isArray(raw) ? fail(400, `${name} must be sent once`, SUBJECT_INVALID) : raw;
|
|
12
|
+
}
|
|
13
|
+
if (values.length > 1)
|
|
14
|
+
return fail(400, `${name} must be sent once`, SUBJECT_INVALID);
|
|
15
|
+
return values[0];
|
|
16
|
+
}
|
|
17
|
+
export async function authenticate(ctx, request) {
|
|
18
|
+
const header = request.headers.authorization;
|
|
19
|
+
const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
|
|
20
|
+
if (!token || !header?.startsWith("Bearer ")) {
|
|
21
|
+
ctx.config.logger.warn("credential rejected", {
|
|
22
|
+
reason: "missing_bearer",
|
|
23
|
+
});
|
|
24
|
+
return failOpaque();
|
|
25
|
+
}
|
|
26
|
+
const tokenHash = hashToken(token);
|
|
27
|
+
const principal = await ctx.store.tx((t) => t.principalByTokenHash(tokenHash));
|
|
28
|
+
const executor = principal ? undefined : ctx.registry.find(tokenHash);
|
|
29
|
+
if (!principal && !executor) {
|
|
30
|
+
ctx.config.logger.warn("credential rejected", {
|
|
31
|
+
reason: "unknown_token",
|
|
32
|
+
});
|
|
33
|
+
return failOpaque();
|
|
34
|
+
}
|
|
35
|
+
// Read only after authentication, so an unknown caller sees the opaque 404 either way.
|
|
36
|
+
const subject = singleHeader(request, SUBJECT_HEADER);
|
|
37
|
+
const scopes = singleHeader(request, SCOPES_HEADER);
|
|
38
|
+
if (executor) {
|
|
39
|
+
if (subject !== undefined || scopes !== undefined)
|
|
40
|
+
fail(403, "An executor credential cannot act for a subject");
|
|
41
|
+
return { kind: "executor", executor };
|
|
42
|
+
}
|
|
43
|
+
if (subject === undefined) {
|
|
44
|
+
if (scopes !== undefined)
|
|
45
|
+
fail(400, `${SCOPES_HEADER} requires ${SUBJECT_HEADER}`, SUBJECT_INVALID);
|
|
46
|
+
return { kind: "application", principalId: principal.id };
|
|
47
|
+
}
|
|
48
|
+
const parsed = parseSubjectHeaders(subject, scopes);
|
|
49
|
+
if (!parsed.ok)
|
|
50
|
+
return fail(400, parsed.message, SUBJECT_INVALID);
|
|
51
|
+
return {
|
|
52
|
+
kind: "subject",
|
|
53
|
+
principalId: principal.id,
|
|
54
|
+
subject: parsed.subject,
|
|
55
|
+
scopes: parsed.scopes,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const SESSIONS = ["sessions:own"];
|
|
59
|
+
const VAULTS = ["vaults:own"];
|
|
60
|
+
const SETTINGS = ["tenant:settings"];
|
|
61
|
+
/**
|
|
62
|
+
* The scopes a subject needs for `method path` (`path` starts with `v1`). `undefined` for a
|
|
63
|
+
* route that does not exist. Operator and executor routes are `never`: reset, config seed,
|
|
64
|
+
* executors, actions and the sandbox tool routes.
|
|
65
|
+
*/
|
|
66
|
+
export function routeAccess(method, path) {
|
|
67
|
+
const [v1, resource, id, sub] = path;
|
|
68
|
+
const n = path.length;
|
|
69
|
+
if (v1 !== "v1" || !resource)
|
|
70
|
+
return undefined;
|
|
71
|
+
switch (resource) {
|
|
72
|
+
case "executors":
|
|
73
|
+
case "actions":
|
|
74
|
+
return "never";
|
|
75
|
+
case "agents":
|
|
76
|
+
if (n === 2 && method === "GET")
|
|
77
|
+
return ["agents:read", "agents:write"];
|
|
78
|
+
if (n === 3 && method === "PUT")
|
|
79
|
+
return ["agents:write"];
|
|
80
|
+
return undefined;
|
|
81
|
+
case "sessions":
|
|
82
|
+
if (n === 2 && method === "GET")
|
|
83
|
+
return SESSIONS;
|
|
84
|
+
if (n === 3 && (method === "GET" || method === "PUT"))
|
|
85
|
+
return SESSIONS;
|
|
86
|
+
if (n === 4 && method === "GET" && (sub === "items" || sub === "events"))
|
|
87
|
+
return SESSIONS;
|
|
88
|
+
if (n === 4 && method === "POST" && sub === "commands")
|
|
89
|
+
return SESSIONS;
|
|
90
|
+
if (n === 5 && method === "POST" && sub === "sandbox")
|
|
91
|
+
return "never";
|
|
92
|
+
return undefined;
|
|
93
|
+
case "vaults":
|
|
94
|
+
if (n === 2)
|
|
95
|
+
return method === "GET" || method === "POST" ? VAULTS : undefined;
|
|
96
|
+
if (n === 3)
|
|
97
|
+
return method === "GET" || method === "DELETE" ? VAULTS : undefined;
|
|
98
|
+
if (sub !== "credentials")
|
|
99
|
+
return undefined;
|
|
100
|
+
if (n === 4)
|
|
101
|
+
return method === "GET" || method === "POST" ? VAULTS : undefined;
|
|
102
|
+
if (n === 5)
|
|
103
|
+
return method === "GET" || method === "POST" || method === "DELETE"
|
|
104
|
+
? VAULTS
|
|
105
|
+
: undefined;
|
|
106
|
+
return undefined;
|
|
107
|
+
case "tenant":
|
|
108
|
+
if (n === 2 && method === "GET")
|
|
109
|
+
return SETTINGS;
|
|
110
|
+
if (n === 3 && id === "reset" && method === "POST")
|
|
111
|
+
return "never";
|
|
112
|
+
if (n === 4 && id === "config" && sub === "seed" && method === "PUT")
|
|
113
|
+
return "never";
|
|
114
|
+
if (n === 3 && method === "GET" && (id === "models" || id === "providers"))
|
|
115
|
+
return ["tenant:settings", "agents:write"];
|
|
116
|
+
if (n === 3 && method === "GET" && id === "sandbox")
|
|
117
|
+
return SETTINGS;
|
|
118
|
+
if (n === 3 && id === "model" && (method === "GET" || method === "PUT"))
|
|
119
|
+
return SETTINGS;
|
|
120
|
+
if (n === 4 && id === "model" && sub === "selection" && method === "PUT")
|
|
121
|
+
return SETTINGS;
|
|
122
|
+
return undefined;
|
|
123
|
+
default:
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Limits a subject to the routes its scopes allow. Decided from the route alone, before any
|
|
129
|
+
* lookup, so a refusal reveals nothing about which resources exist. A route the table does not
|
|
130
|
+
* know is a 404 for a subject: new routes stay closed until they are added here. Application
|
|
131
|
+
* and executor scopes pass through to the checks each route makes today.
|
|
132
|
+
*/
|
|
133
|
+
export function authorize(scope, method, path) {
|
|
134
|
+
if (scope.kind !== "subject")
|
|
135
|
+
return;
|
|
136
|
+
const access = routeAccess(method, path);
|
|
137
|
+
if (access === undefined)
|
|
138
|
+
fail(404, "Route not found");
|
|
139
|
+
if (access === "never")
|
|
140
|
+
fail(403, "This route is not available when acting for a subject", {
|
|
141
|
+
code: "scope_required",
|
|
142
|
+
details: { scopes: [] },
|
|
143
|
+
});
|
|
144
|
+
if (!access.some((name) => scope.scopes.has(name)))
|
|
145
|
+
fail(403, `Scope ${access.join(" or ")} required`, {
|
|
146
|
+
code: "scope_required",
|
|
147
|
+
details: { scopes: access },
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** The subject whose sessions and vaults the request is limited to, if it acts for one. */
|
|
151
|
+
export function ownerOf(scope) {
|
|
152
|
+
return scope.kind === "subject" ? scope.subject : undefined;
|
|
153
|
+
}
|
|
154
|
+
/** The application principal behind the request, acting for a subject or not. */
|
|
155
|
+
export function requirePrincipal(scope) {
|
|
156
|
+
if (scope.kind !== "executor")
|
|
157
|
+
return scope.principalId;
|
|
158
|
+
return fail(403, "Application credential required");
|
|
159
|
+
}
|
|
160
|
+
/** The executor scope must belong to the Action's agent. */
|
|
161
|
+
export function scoped(scope, action) {
|
|
162
|
+
if (scope.kind !== "executor" || scope.executor.agentId !== action.agentId)
|
|
163
|
+
fail(403, "Executor scope does not authorize this action");
|
|
164
|
+
}
|
|
165
|
+
export function requireApplication(scope) {
|
|
166
|
+
if (scope.kind === "application")
|
|
167
|
+
return scope.principalId;
|
|
168
|
+
return fail(403, "Application credential required");
|
|
169
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session command service: `message`, `approve`/`respond`, `action_result` and `cancel`,
|
|
3
|
+
* with per-session idempotency keys. A command locks its session and commits its state
|
|
4
|
+
* change, events and wakes in one transaction (the store publishes the events and runs the
|
|
5
|
+
* `afterCommit` wakes), then aborts a cancelled advance and cascades workflow cancels to the
|
|
6
|
+
* linked agent sessions, one transaction each.
|
|
7
|
+
*
|
|
8
|
+
* Wakes (`ctx.wake`, architecture §12.3) carry the command type as the reason and a dedupe
|
|
9
|
+
* key naming the cause: `<type>:<turnId>:<segment>` for `message`, `approve` and `respond`
|
|
10
|
+
* (every accepted one writes a new checkpoint segment), and
|
|
11
|
+
* `action_result:<turnId>:<actionId>:<generation>` for executor results.
|
|
12
|
+
*
|
|
13
|
+
* Cancel commits `cancelled` first; the engine host sees it before its next effect and before
|
|
14
|
+
* settlement on any Worker. It then aborts an advance running on this process
|
|
15
|
+
* (`ctx.abortLocal`); reaching an advance on another process is the control stream's job.
|
|
16
|
+
*/
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { createDurableCheckpoint, createFlowCheckpoint, } from "@nylorun/harness/run";
|
|
19
|
+
import { schemaFromJSON, } from "@nylorun/core/define";
|
|
20
|
+
import { commandKey, fenceWorkflowActions, foreignInteractionConflict, isWorkflowManifest, planCancelCascade, wakeForQueuedEffects, wakeLinkedWorkflow, } from "../core/flow-host.js";
|
|
21
|
+
import { resolveMessageManifest } from "../core/turn-manifest.js";
|
|
22
|
+
import { canonical } from "../store/canonical.js";
|
|
23
|
+
import { lockedSession, } from "./context.js";
|
|
24
|
+
import { fail } from "./http.js";
|
|
25
|
+
import { ownerOf, scoped } from "./auth.js";
|
|
26
|
+
import { actionTarget, rebaseSessionState, turnManifestOf, variantStore, } from "./session.js";
|
|
27
|
+
import { signalSessionCancel } from "./streams.js";
|
|
28
|
+
import { toolIds } from "./transcript.js";
|
|
29
|
+
/** A tool result whose output does not match the Action's stored output schema fails the tool. */
|
|
30
|
+
function acceptedToolResult(action, command) {
|
|
31
|
+
if (action.kind !== "tool" || !action.outputSchema)
|
|
32
|
+
return command;
|
|
33
|
+
const value = command.outcome.value;
|
|
34
|
+
// Only a result carries output: failures, denials, interactions and deferrals pass as sent.
|
|
35
|
+
if (!isResultToolValue(value))
|
|
36
|
+
return command;
|
|
37
|
+
// Executors wrap successful tool output as `{ kind: "completed", output }`.
|
|
38
|
+
// Validate the tool payload, not the outcome envelope.
|
|
39
|
+
const candidate = completedToolOutput(value);
|
|
40
|
+
let matches = false;
|
|
41
|
+
try {
|
|
42
|
+
matches = schemaFromJSON(action.outputSchema).validate(candidate).ok;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
matches = false;
|
|
46
|
+
}
|
|
47
|
+
if (matches)
|
|
48
|
+
return command;
|
|
49
|
+
return {
|
|
50
|
+
...command,
|
|
51
|
+
outcome: {
|
|
52
|
+
...command.outcome,
|
|
53
|
+
value: {
|
|
54
|
+
kind: "failed",
|
|
55
|
+
code: "tool.invalid-output",
|
|
56
|
+
message: "Tool result does not match the output schema stored on the action",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function completedToolOutput(value) {
|
|
62
|
+
if (value &&
|
|
63
|
+
typeof value === "object" &&
|
|
64
|
+
!Array.isArray(value) &&
|
|
65
|
+
value.kind === "completed" &&
|
|
66
|
+
"output" in value) {
|
|
67
|
+
return value.output;
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
const NON_RESULT_KINDS = new Set([
|
|
72
|
+
"failed",
|
|
73
|
+
"denied",
|
|
74
|
+
"interaction-required",
|
|
75
|
+
"deferred",
|
|
76
|
+
]);
|
|
77
|
+
function isResultToolValue(value) {
|
|
78
|
+
return !(!!value &&
|
|
79
|
+
typeof value === "object" &&
|
|
80
|
+
!Array.isArray(value) &&
|
|
81
|
+
NON_RESULT_KINDS.has(value.kind));
|
|
82
|
+
}
|
|
83
|
+
/** A command's identity for idempotency: everything but the request id. */
|
|
84
|
+
const semantic = (value) => {
|
|
85
|
+
const { requestId: _, ...body } = value;
|
|
86
|
+
return canonical(body);
|
|
87
|
+
};
|
|
88
|
+
export async function command(ctx, id, input, scope) {
|
|
89
|
+
const { store } = ctx;
|
|
90
|
+
const cascadeCancelIds = [];
|
|
91
|
+
const response = await store.tx(async (t) => {
|
|
92
|
+
const s = await lockedSession(t, id, ownerOf(scope));
|
|
93
|
+
let command = input;
|
|
94
|
+
if (command.type === "action_result") {
|
|
95
|
+
const a = (await t.get("actions", command.actionId)) ??
|
|
96
|
+
fail(404, "Action not found");
|
|
97
|
+
if (a.sessionId !== id)
|
|
98
|
+
fail(403, "Action belongs to another session");
|
|
99
|
+
scoped(scope, a);
|
|
100
|
+
command = acceptedToolResult(a, command);
|
|
101
|
+
}
|
|
102
|
+
else if (scope.kind === "executor")
|
|
103
|
+
fail(403, "Application credential required");
|
|
104
|
+
const key = commandKey(id, command.idempotencyKey);
|
|
105
|
+
const existing = await t.get("commands", key);
|
|
106
|
+
if (existing) {
|
|
107
|
+
if (semantic(existing.command) !== semantic(command))
|
|
108
|
+
fail(409, "Idempotency key already binds another command");
|
|
109
|
+
return existing.response;
|
|
110
|
+
}
|
|
111
|
+
let event;
|
|
112
|
+
// The turn a `cancel` ended, if one was active.
|
|
113
|
+
let cancelledTurnId = null;
|
|
114
|
+
if (command.type === "action_result") {
|
|
115
|
+
const action = (await t.get("actions", command.actionId));
|
|
116
|
+
const prior = await t.get("effects", command.actionId);
|
|
117
|
+
if (action.status === "completed") {
|
|
118
|
+
if (action.claimId !== command.claimId ||
|
|
119
|
+
action.generation !== command.generation ||
|
|
120
|
+
canonical(prior.outcome) !== canonical(command.outcome))
|
|
121
|
+
fail(409, "Conflicting action result");
|
|
122
|
+
await t.put("commands", key, { command, response: prior.receipt });
|
|
123
|
+
return prior.receipt;
|
|
124
|
+
}
|
|
125
|
+
if (s.status === "cancelled" ||
|
|
126
|
+
s.activeTurnId !== action.turnId ||
|
|
127
|
+
action.status !== "claimed" ||
|
|
128
|
+
action.claimId !== command.claimId ||
|
|
129
|
+
action.generation !== command.generation ||
|
|
130
|
+
Date.parse(action.leaseExpiresAt) <= Date.now())
|
|
131
|
+
fail(409, "Stale, expired, or cancelled claim");
|
|
132
|
+
action.status = "completed";
|
|
133
|
+
await t.put("actions", action.actionId, action);
|
|
134
|
+
prior.status = "completed";
|
|
135
|
+
prior.outcome = command.outcome;
|
|
136
|
+
s.status = "runnable";
|
|
137
|
+
const resultWake = {
|
|
138
|
+
reason: "action_result",
|
|
139
|
+
dedupeKey: `action_result:${action.turnId}:${action.actionId}:${action.generation}`,
|
|
140
|
+
};
|
|
141
|
+
t.afterCommit(() => ctx.wake(id, resultWake));
|
|
142
|
+
event = await t.event(id, s.activeTurnId, "action.completed", {
|
|
143
|
+
actionId: action.actionId,
|
|
144
|
+
...actionTarget(action),
|
|
145
|
+
kind: action.kind,
|
|
146
|
+
...(action.kind === "tool" ? toolIds(action.context) : {}),
|
|
147
|
+
result: command.outcome.value,
|
|
148
|
+
});
|
|
149
|
+
if (action.kind === "verify") {
|
|
150
|
+
const verdict = command.outcome.value;
|
|
151
|
+
if (verdict?.kind !== "failed") {
|
|
152
|
+
await t.event(id, s.activeTurnId, "loop.verified", {
|
|
153
|
+
path: String(action.context?.loopPath ?? action.path ?? ""),
|
|
154
|
+
n: Number(action.context?.n ?? 1),
|
|
155
|
+
pass: Boolean(verdict?.pass),
|
|
156
|
+
...(verdict?.feedback !== undefined
|
|
157
|
+
? { feedback: verdict.feedback }
|
|
158
|
+
: {}),
|
|
159
|
+
...(verdict?.data !== undefined ? { data: verdict.data } : {}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else if (action.kind === "fn" && action.context?.role === "decide") {
|
|
164
|
+
const decision = command.outcome.value;
|
|
165
|
+
await t.event(id, s.activeTurnId, "loop.decided", {
|
|
166
|
+
path: String(action.context?.loopPath ?? action.path ?? ""),
|
|
167
|
+
n: Number(action.context?.n ?? 1),
|
|
168
|
+
next: "output" in decision && !("input" in decision)
|
|
169
|
+
? "output"
|
|
170
|
+
: "input",
|
|
171
|
+
patched: Boolean(decision.agent),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
const receipt = {
|
|
175
|
+
status: "accepted",
|
|
176
|
+
turnId: s.activeTurnId,
|
|
177
|
+
cursor: event.cursor,
|
|
178
|
+
requestId: command.requestId,
|
|
179
|
+
};
|
|
180
|
+
prior.receipt = receipt;
|
|
181
|
+
await t.put("effects", action.actionId, prior);
|
|
182
|
+
if (isWorkflowManifest(s.manifest) && s.activeTurnId) {
|
|
183
|
+
await wakeForQueuedEffects({
|
|
184
|
+
t,
|
|
185
|
+
workflowSessionId: id,
|
|
186
|
+
turnId: s.activeTurnId,
|
|
187
|
+
limits: ctx.flowLimits,
|
|
188
|
+
schedule: ctx.wake,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (command.type === "cancel") {
|
|
193
|
+
cancelledTurnId = s.activeTurnId;
|
|
194
|
+
const workflowCancel = isWorkflowManifest(s.manifest);
|
|
195
|
+
const cascade = workflowCancel
|
|
196
|
+
? await planCancelCascade({
|
|
197
|
+
t,
|
|
198
|
+
workflowSessionId: id,
|
|
199
|
+
turnId: cancelledTurnId,
|
|
200
|
+
})
|
|
201
|
+
: null;
|
|
202
|
+
s.status = "cancelled";
|
|
203
|
+
if (workflowCancel) {
|
|
204
|
+
await fenceWorkflowActions({
|
|
205
|
+
t,
|
|
206
|
+
workflowSessionId: id,
|
|
207
|
+
turnId: cancelledTurnId,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else if (cancelledTurnId !== null) {
|
|
211
|
+
for (const a of await t.actionsForSession(id, {
|
|
212
|
+
turnId: cancelledTurnId,
|
|
213
|
+
statuses: ["pending", "claimed"],
|
|
214
|
+
})) {
|
|
215
|
+
// Claimed work may already have an external effect. Preserve it for reconciliation.
|
|
216
|
+
a.status = a.status === "claimed" ? "uncertain" : "cancelled";
|
|
217
|
+
await t.put("actions", a.actionId, a);
|
|
218
|
+
const effect = await t.get("effects", a.actionId);
|
|
219
|
+
if (effect) {
|
|
220
|
+
effect.status = a.status;
|
|
221
|
+
await t.put("effects", a.actionId, effect);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (cancelledTurnId !== null)
|
|
226
|
+
for (const effect of await t.effectsForTurn(id, cancelledTurnId, ["invoking"])) {
|
|
227
|
+
effect.status = "uncertain";
|
|
228
|
+
effect.error =
|
|
229
|
+
"Turn cancelled before model outcome was durably recorded";
|
|
230
|
+
await t.put("effects", effect.request.effectId, effect);
|
|
231
|
+
}
|
|
232
|
+
event = await t.event(id, cancelledTurnId, "turn.cancelled", {
|
|
233
|
+
reason: command.reason,
|
|
234
|
+
});
|
|
235
|
+
// The process running the advance aborts it on `session.cancel` (tenant/control).
|
|
236
|
+
t.afterCommit(() => signalSessionCancel(ctx, id));
|
|
237
|
+
s.activeTurnId = null;
|
|
238
|
+
if (cancelledTurnId !== null)
|
|
239
|
+
s.lastTurnId = cancelledTurnId;
|
|
240
|
+
// The next turn starts from the state preceding the cancelled turn, never its paused plan.
|
|
241
|
+
if (cancelledTurnId !== null)
|
|
242
|
+
s.state = s.turnStartState;
|
|
243
|
+
s.checkpoint = undefined;
|
|
244
|
+
s.waits = undefined;
|
|
245
|
+
s.error = undefined;
|
|
246
|
+
// Cascade: cancel linked agent sessions deepest-first (after fencing this session), each
|
|
247
|
+
// in its own transaction after this one commits (child sessions are never locked here).
|
|
248
|
+
if (cascade) {
|
|
249
|
+
for (const agentId of cascade.agentSessionIds) {
|
|
250
|
+
const agent = await t.get("sessions", agentId);
|
|
251
|
+
if (!agent ||
|
|
252
|
+
!agent.activeTurnId ||
|
|
253
|
+
["idle", "completed", "failed", "cancelled"].includes(agent.status))
|
|
254
|
+
continue;
|
|
255
|
+
cascadeCancelIds.push(agentId);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
if (command.type === "message") {
|
|
261
|
+
if (!["idle", "completed", "failed", "cancelled"].includes(s.status))
|
|
262
|
+
fail(409, "Session has active or unresolved work");
|
|
263
|
+
s.turnStartState = s.state;
|
|
264
|
+
s.activeTurnId = randomUUID();
|
|
265
|
+
if (isWorkflowManifest(s.manifest)) {
|
|
266
|
+
const input = "content" in command ? command.content : command.data;
|
|
267
|
+
s.checkpoint = createFlowCheckpoint({
|
|
268
|
+
manifest: s.manifest,
|
|
269
|
+
sessionId: id,
|
|
270
|
+
turnId: s.activeTurnId,
|
|
271
|
+
input,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
// Optional message.manifest: validate as variant, pin by hash, apply this turn only.
|
|
276
|
+
const resolved = resolveMessageManifest({
|
|
277
|
+
pinned: s.manifest,
|
|
278
|
+
pinnedHash: s.manifestHash,
|
|
279
|
+
messageManifest: command.manifest,
|
|
280
|
+
store: variantStore(s),
|
|
281
|
+
});
|
|
282
|
+
if (!resolved.ok) {
|
|
283
|
+
fail(400, resolved.message);
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
rebaseSessionState(s, resolved.hash);
|
|
287
|
+
s.checkpoint = createDurableCheckpoint({
|
|
288
|
+
manifest: resolved.manifest,
|
|
289
|
+
sessionId: id,
|
|
290
|
+
turnId: s.activeTurnId,
|
|
291
|
+
input: "content" in command
|
|
292
|
+
? command.content
|
|
293
|
+
: typeof command.data === "string"
|
|
294
|
+
? command.data
|
|
295
|
+
: JSON.stringify(command.data),
|
|
296
|
+
state: s.state,
|
|
297
|
+
info: s.info,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
if (s.status !== "paused" || !s.state || !s.activeTurnId)
|
|
304
|
+
fail(409, "Session is not awaiting a response");
|
|
305
|
+
const pending = s.state.plan?.calls ?? [];
|
|
306
|
+
if (!pending.some((c) => c.status === "interaction" &&
|
|
307
|
+
c.interaction?.id === command.interactionId &&
|
|
308
|
+
(command.type === "approve") ===
|
|
309
|
+
(c.interaction?.kind === "approval")))
|
|
310
|
+
fail(409, "Unknown interaction");
|
|
311
|
+
const input = command.type === "approve"
|
|
312
|
+
? {
|
|
313
|
+
kind: "approve",
|
|
314
|
+
interactionId: command.interactionId,
|
|
315
|
+
approved: command.approved,
|
|
316
|
+
}
|
|
317
|
+
: {
|
|
318
|
+
kind: "respond",
|
|
319
|
+
interactionId: command.interactionId,
|
|
320
|
+
value: command.value,
|
|
321
|
+
};
|
|
322
|
+
if (isWorkflowManifest(s.manifest)) {
|
|
323
|
+
// Approvals owned by linked agent sessions must be answered there (WF-R52).
|
|
324
|
+
const conflict = await foreignInteractionConflict({
|
|
325
|
+
t,
|
|
326
|
+
workflowSessionId: id,
|
|
327
|
+
interactionId: command.interactionId,
|
|
328
|
+
});
|
|
329
|
+
if (conflict)
|
|
330
|
+
fail(409, conflict.message);
|
|
331
|
+
// Workflow-owned interactions (tool-node / verify) resume on this session once
|
|
332
|
+
// the flow engine supports pause segments; tracer root Loop has none yet.
|
|
333
|
+
fail(409, "Workflow sessions do not accept approve/respond on the root without a pending interaction");
|
|
334
|
+
}
|
|
335
|
+
s.checkpoint = createDurableCheckpoint({
|
|
336
|
+
manifest: turnManifestOf(s),
|
|
337
|
+
sessionId: id,
|
|
338
|
+
turnId: s.activeTurnId,
|
|
339
|
+
input: input,
|
|
340
|
+
state: s.state,
|
|
341
|
+
info: s.info,
|
|
342
|
+
segment: (s.checkpoint?.segment ?? 0) + 1,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
await t.put("checkpoints", JSON.stringify([id, s.activeTurnId, s.checkpoint.segment]), { checkpoint: s.checkpoint, status: "runnable" });
|
|
346
|
+
s.status = "runnable";
|
|
347
|
+
s.waits = undefined;
|
|
348
|
+
const commandWake = {
|
|
349
|
+
reason: command.type,
|
|
350
|
+
dedupeKey: `${command.type}:${s.activeTurnId}:${s.checkpoint.segment}`,
|
|
351
|
+
};
|
|
352
|
+
t.afterCommit(() => ctx.wake(id, commandWake));
|
|
353
|
+
event = await t.event(id, s.activeTurnId, `command.${command.type}`, command);
|
|
354
|
+
}
|
|
355
|
+
await t.put("sessions", id, s);
|
|
356
|
+
// Direct cancel of a linked agent fails that node with agent.cancelled (WF-R54). The agent
|
|
357
|
+
// session is locked above, before wakeLinkedWorkflow locks its workflow.
|
|
358
|
+
if (command.type === "cancel")
|
|
359
|
+
await wakeLinkedWorkflow({
|
|
360
|
+
t,
|
|
361
|
+
agentSessionId: id,
|
|
362
|
+
turnId: cancelledTurnId,
|
|
363
|
+
cancelled: true,
|
|
364
|
+
error: "Agent turn was cancelled",
|
|
365
|
+
schedule: ctx.wake,
|
|
366
|
+
});
|
|
367
|
+
const response = {
|
|
368
|
+
status: "accepted",
|
|
369
|
+
turnId: s.activeTurnId,
|
|
370
|
+
cursor: event.cursor,
|
|
371
|
+
requestId: command.requestId,
|
|
372
|
+
};
|
|
373
|
+
await t.put("commands", key, { command, response });
|
|
374
|
+
return response;
|
|
375
|
+
});
|
|
376
|
+
if (input.type === "cancel")
|
|
377
|
+
ctx.abortLocal(id);
|
|
378
|
+
// Cascade cancel linked agents deepest-first under existing fencing (WF-R53).
|
|
379
|
+
for (const agentId of cascadeCancelIds) {
|
|
380
|
+
try {
|
|
381
|
+
await command(ctx, agentId, {
|
|
382
|
+
type: "cancel",
|
|
383
|
+
requestId: `flow-cascade-${id}-${agentId}`,
|
|
384
|
+
idempotencyKey: `flow-cascade-cancel:${id}:${agentId}`,
|
|
385
|
+
reason: "workflow cancelled",
|
|
386
|
+
}, scope);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
/* agent may already be terminal */
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
return response;
|
|
393
|
+
}
|