@nylorun/runtime 0.10.0-beta → 0.11.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 +46 -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 +8 -3
- package/dist/core/sandbox-routes.js +10 -10
- 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 +48 -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 +643 -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/index.d.ts +49 -0
- package/dist/store/postgres/migrations/index.js +75 -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 +790 -0
- package/dist/store/postgres/tenants.d.ts +90 -0
- package/dist/store/postgres/tenants.js +207 -0
- package/dist/store/types.d.ts +440 -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 +11 -0
- package/dist/tenant/auth.js +34 -0
- package/dist/tenant/commands.d.ts +3 -0
- package/dist/tenant/commands.js +384 -0
- package/dist/tenant/context.d.ts +140 -0
- package/dist/tenant/context.js +40 -0
- package/dist/tenant/effects.d.ts +20 -0
- package/dist/tenant/effects.js +539 -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 +32 -0
- package/dist/tenant/http.js +51 -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 +162 -158
- package/dist/tenant/paths.d.ts +12 -8
- package/dist/tenant/paths.js +38 -12
- package/dist/tenant/principals.d.ts +9 -13
- package/dist/tenant/principals.js +31 -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 +126 -0
- package/dist/tenant/routes.d.ts +9 -0
- package/dist/tenant/routes.js +142 -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 +34 -0
- package/dist/tenant/sessions.js +149 -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/types.d.ts +95 -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 +37 -31
- package/dist/vault/service.js +439 -359
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +13 -13
- 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,34 @@
|
|
|
1
|
+
import { hashToken } from "../core/executors.js";
|
|
2
|
+
import { fail, failOpaque } from "./http.js";
|
|
3
|
+
export async function authenticate(ctx, request) {
|
|
4
|
+
const header = request.headers.authorization;
|
|
5
|
+
const token = header?.startsWith("Bearer ") ? header.slice(7) : "";
|
|
6
|
+
if (!token || !header?.startsWith("Bearer ")) {
|
|
7
|
+
ctx.config.logger.warn("credential rejected", {
|
|
8
|
+
reason: "missing_bearer",
|
|
9
|
+
});
|
|
10
|
+
return failOpaque();
|
|
11
|
+
}
|
|
12
|
+
const tokenHash = hashToken(token);
|
|
13
|
+
const principal = await ctx.store.tx((t) => t.principalByTokenHash(tokenHash));
|
|
14
|
+
if (principal)
|
|
15
|
+
return { kind: "application", principalId: principal.id };
|
|
16
|
+
const executor = ctx.registry.find(tokenHash);
|
|
17
|
+
if (!executor) {
|
|
18
|
+
ctx.config.logger.warn("credential rejected", {
|
|
19
|
+
reason: "unknown_token",
|
|
20
|
+
});
|
|
21
|
+
return failOpaque();
|
|
22
|
+
}
|
|
23
|
+
return { kind: "executor", executor };
|
|
24
|
+
}
|
|
25
|
+
/** The executor scope must belong to the Action's agent. */
|
|
26
|
+
export function scoped(scope, action) {
|
|
27
|
+
if (scope.kind !== "executor" || scope.executor.agentId !== action.agentId)
|
|
28
|
+
fail(403, "Executor scope does not authorize this action");
|
|
29
|
+
}
|
|
30
|
+
export function requireApplication(scope) {
|
|
31
|
+
if (scope.kind === "application")
|
|
32
|
+
return scope.principalId;
|
|
33
|
+
return fail(403, "Application credential required");
|
|
34
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
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 { scoped } from "./auth.js";
|
|
26
|
+
import { actionTarget, rebaseSessionState, turnManifestOf, variantStore, } from "./session.js";
|
|
27
|
+
import { signalSessionCancel } from "./streams.js";
|
|
28
|
+
/** A tool result whose output does not match the Action's stored output schema fails the tool. */
|
|
29
|
+
function acceptedToolResult(action, command) {
|
|
30
|
+
if (action.kind !== "tool" || !action.outputSchema)
|
|
31
|
+
return command;
|
|
32
|
+
const value = command.outcome.value;
|
|
33
|
+
if (isFailedToolValue(value))
|
|
34
|
+
return command;
|
|
35
|
+
// Executors wrap successful tool output as `{ kind: "completed", output }`.
|
|
36
|
+
// Validate the tool payload, not the outcome envelope.
|
|
37
|
+
const candidate = completedToolOutput(value);
|
|
38
|
+
let matches = false;
|
|
39
|
+
try {
|
|
40
|
+
matches = schemaFromJSON(action.outputSchema).validate(candidate).ok;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
matches = false;
|
|
44
|
+
}
|
|
45
|
+
if (matches)
|
|
46
|
+
return command;
|
|
47
|
+
return {
|
|
48
|
+
...command,
|
|
49
|
+
outcome: {
|
|
50
|
+
...command.outcome,
|
|
51
|
+
value: {
|
|
52
|
+
kind: "failed",
|
|
53
|
+
code: "tool.invalid-output",
|
|
54
|
+
message: "Tool result does not match the output schema stored on the action",
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function completedToolOutput(value) {
|
|
60
|
+
if (value &&
|
|
61
|
+
typeof value === "object" &&
|
|
62
|
+
!Array.isArray(value) &&
|
|
63
|
+
value.kind === "completed" &&
|
|
64
|
+
"output" in value) {
|
|
65
|
+
return value.output;
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function isFailedToolValue(value) {
|
|
70
|
+
return (!!value &&
|
|
71
|
+
typeof value === "object" &&
|
|
72
|
+
!Array.isArray(value) &&
|
|
73
|
+
value.kind === "failed");
|
|
74
|
+
}
|
|
75
|
+
/** A command's identity for idempotency: everything but the request id. */
|
|
76
|
+
const semantic = (value) => {
|
|
77
|
+
const { requestId: _, ...body } = value;
|
|
78
|
+
return canonical(body);
|
|
79
|
+
};
|
|
80
|
+
export async function command(ctx, id, input, scope) {
|
|
81
|
+
const { store } = ctx;
|
|
82
|
+
const cascadeCancelIds = [];
|
|
83
|
+
const response = await store.tx(async (t) => {
|
|
84
|
+
const s = await lockedSession(t, id);
|
|
85
|
+
let command = input;
|
|
86
|
+
if (command.type === "action_result") {
|
|
87
|
+
const a = (await t.get("actions", command.actionId)) ??
|
|
88
|
+
fail(404, "Action not found");
|
|
89
|
+
if (a.sessionId !== id)
|
|
90
|
+
fail(403, "Action belongs to another session");
|
|
91
|
+
scoped(scope, a);
|
|
92
|
+
command = acceptedToolResult(a, command);
|
|
93
|
+
}
|
|
94
|
+
else if (scope.kind !== "application")
|
|
95
|
+
fail(403, "Application credential required");
|
|
96
|
+
const key = commandKey(id, command.idempotencyKey);
|
|
97
|
+
const existing = await t.get("commands", key);
|
|
98
|
+
if (existing) {
|
|
99
|
+
if (semantic(existing.command) !== semantic(command))
|
|
100
|
+
fail(409, "Idempotency key already binds another command");
|
|
101
|
+
return existing.response;
|
|
102
|
+
}
|
|
103
|
+
let event;
|
|
104
|
+
// The turn a `cancel` ended, if one was active.
|
|
105
|
+
let cancelledTurnId = null;
|
|
106
|
+
if (command.type === "action_result") {
|
|
107
|
+
const action = (await t.get("actions", command.actionId));
|
|
108
|
+
const prior = await t.get("effects", command.actionId);
|
|
109
|
+
if (action.status === "completed") {
|
|
110
|
+
if (action.claimId !== command.claimId ||
|
|
111
|
+
action.generation !== command.generation ||
|
|
112
|
+
canonical(prior.outcome) !== canonical(command.outcome))
|
|
113
|
+
fail(409, "Conflicting action result");
|
|
114
|
+
await t.put("commands", key, { command, response: prior.receipt });
|
|
115
|
+
return prior.receipt;
|
|
116
|
+
}
|
|
117
|
+
if (s.status === "cancelled" ||
|
|
118
|
+
s.activeTurnId !== action.turnId ||
|
|
119
|
+
action.status !== "claimed" ||
|
|
120
|
+
action.claimId !== command.claimId ||
|
|
121
|
+
action.generation !== command.generation ||
|
|
122
|
+
Date.parse(action.leaseExpiresAt) <= Date.now())
|
|
123
|
+
fail(409, "Stale, expired, or cancelled claim");
|
|
124
|
+
action.status = "completed";
|
|
125
|
+
await t.put("actions", action.actionId, action);
|
|
126
|
+
prior.status = "completed";
|
|
127
|
+
prior.outcome = command.outcome;
|
|
128
|
+
s.status = "runnable";
|
|
129
|
+
const resultWake = {
|
|
130
|
+
reason: "action_result",
|
|
131
|
+
dedupeKey: `action_result:${action.turnId}:${action.actionId}:${action.generation}`,
|
|
132
|
+
};
|
|
133
|
+
t.afterCommit(() => ctx.wake(id, resultWake));
|
|
134
|
+
event = await t.event(id, s.activeTurnId, "action.completed", {
|
|
135
|
+
actionId: action.actionId,
|
|
136
|
+
...actionTarget(action),
|
|
137
|
+
kind: action.kind,
|
|
138
|
+
result: command.outcome.value,
|
|
139
|
+
});
|
|
140
|
+
if (action.kind === "verify") {
|
|
141
|
+
const verdict = command.outcome.value;
|
|
142
|
+
if (verdict?.kind !== "failed") {
|
|
143
|
+
await t.event(id, s.activeTurnId, "loop.verified", {
|
|
144
|
+
path: String(action.context?.loopPath ?? action.path ?? ""),
|
|
145
|
+
n: Number(action.context?.n ?? 1),
|
|
146
|
+
pass: Boolean(verdict?.pass),
|
|
147
|
+
...(verdict?.feedback !== undefined
|
|
148
|
+
? { feedback: verdict.feedback }
|
|
149
|
+
: {}),
|
|
150
|
+
...(verdict?.data !== undefined ? { data: verdict.data } : {}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
else if (action.kind === "fn" && action.context?.role === "decide") {
|
|
155
|
+
const decision = command.outcome.value;
|
|
156
|
+
await t.event(id, s.activeTurnId, "loop.decided", {
|
|
157
|
+
path: String(action.context?.loopPath ?? action.path ?? ""),
|
|
158
|
+
n: Number(action.context?.n ?? 1),
|
|
159
|
+
next: "output" in decision && !("input" in decision)
|
|
160
|
+
? "output"
|
|
161
|
+
: "input",
|
|
162
|
+
patched: Boolean(decision.agent),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const receipt = {
|
|
166
|
+
status: "accepted",
|
|
167
|
+
turnId: s.activeTurnId,
|
|
168
|
+
cursor: event.cursor,
|
|
169
|
+
requestId: command.requestId,
|
|
170
|
+
};
|
|
171
|
+
prior.receipt = receipt;
|
|
172
|
+
await t.put("effects", action.actionId, prior);
|
|
173
|
+
if (isWorkflowManifest(s.manifest) && s.activeTurnId) {
|
|
174
|
+
await wakeForQueuedEffects({
|
|
175
|
+
t,
|
|
176
|
+
workflowSessionId: id,
|
|
177
|
+
turnId: s.activeTurnId,
|
|
178
|
+
limits: ctx.flowLimits,
|
|
179
|
+
schedule: ctx.wake,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (command.type === "cancel") {
|
|
184
|
+
cancelledTurnId = s.activeTurnId;
|
|
185
|
+
const workflowCancel = isWorkflowManifest(s.manifest);
|
|
186
|
+
const cascade = workflowCancel
|
|
187
|
+
? await planCancelCascade({
|
|
188
|
+
t,
|
|
189
|
+
workflowSessionId: id,
|
|
190
|
+
turnId: cancelledTurnId,
|
|
191
|
+
})
|
|
192
|
+
: null;
|
|
193
|
+
s.status = "cancelled";
|
|
194
|
+
if (workflowCancel) {
|
|
195
|
+
await fenceWorkflowActions({
|
|
196
|
+
t,
|
|
197
|
+
workflowSessionId: id,
|
|
198
|
+
turnId: cancelledTurnId,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
else if (cancelledTurnId !== null) {
|
|
202
|
+
for (const a of await t.actionsForSession(id, {
|
|
203
|
+
turnId: cancelledTurnId,
|
|
204
|
+
statuses: ["pending", "claimed"],
|
|
205
|
+
})) {
|
|
206
|
+
// Claimed work may already have an external effect. Preserve it for reconciliation.
|
|
207
|
+
a.status = a.status === "claimed" ? "uncertain" : "cancelled";
|
|
208
|
+
await t.put("actions", a.actionId, a);
|
|
209
|
+
const effect = await t.get("effects", a.actionId);
|
|
210
|
+
if (effect) {
|
|
211
|
+
effect.status = a.status;
|
|
212
|
+
await t.put("effects", a.actionId, effect);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (cancelledTurnId !== null)
|
|
217
|
+
for (const effect of await t.effectsForTurn(id, cancelledTurnId, ["invoking"])) {
|
|
218
|
+
effect.status = "uncertain";
|
|
219
|
+
effect.error =
|
|
220
|
+
"Turn cancelled before model outcome was durably recorded";
|
|
221
|
+
await t.put("effects", effect.request.effectId, effect);
|
|
222
|
+
}
|
|
223
|
+
event = await t.event(id, cancelledTurnId, "turn.cancelled", {
|
|
224
|
+
reason: command.reason,
|
|
225
|
+
});
|
|
226
|
+
// The process running the advance aborts it on `session.cancel` (tenant/control).
|
|
227
|
+
t.afterCommit(() => signalSessionCancel(ctx, id));
|
|
228
|
+
s.activeTurnId = null;
|
|
229
|
+
if (cancelledTurnId !== null)
|
|
230
|
+
s.lastTurnId = cancelledTurnId;
|
|
231
|
+
// The next turn starts from the state preceding the cancelled turn, never its paused plan.
|
|
232
|
+
if (cancelledTurnId !== null)
|
|
233
|
+
s.state = s.turnStartState;
|
|
234
|
+
s.checkpoint = undefined;
|
|
235
|
+
s.waits = undefined;
|
|
236
|
+
s.error = undefined;
|
|
237
|
+
// Cascade: cancel linked agent sessions deepest-first (after fencing this session), each
|
|
238
|
+
// in its own transaction after this one commits (child sessions are never locked here).
|
|
239
|
+
if (cascade) {
|
|
240
|
+
for (const agentId of cascade.agentSessionIds) {
|
|
241
|
+
const agent = await t.get("sessions", agentId);
|
|
242
|
+
if (!agent ||
|
|
243
|
+
!agent.activeTurnId ||
|
|
244
|
+
["idle", "completed", "failed", "cancelled"].includes(agent.status))
|
|
245
|
+
continue;
|
|
246
|
+
cascadeCancelIds.push(agentId);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
else {
|
|
251
|
+
if (command.type === "message") {
|
|
252
|
+
if (!["idle", "completed", "failed", "cancelled"].includes(s.status))
|
|
253
|
+
fail(409, "Session has active or unresolved work");
|
|
254
|
+
s.turnStartState = s.state;
|
|
255
|
+
s.activeTurnId = randomUUID();
|
|
256
|
+
if (isWorkflowManifest(s.manifest)) {
|
|
257
|
+
const input = "content" in command ? command.content : command.data;
|
|
258
|
+
s.checkpoint = createFlowCheckpoint({
|
|
259
|
+
manifest: s.manifest,
|
|
260
|
+
sessionId: id,
|
|
261
|
+
turnId: s.activeTurnId,
|
|
262
|
+
input,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
// Optional message.manifest: validate as variant, pin by hash, apply this turn only.
|
|
267
|
+
const resolved = resolveMessageManifest({
|
|
268
|
+
pinned: s.manifest,
|
|
269
|
+
pinnedHash: s.manifestHash,
|
|
270
|
+
messageManifest: command.manifest,
|
|
271
|
+
store: variantStore(s),
|
|
272
|
+
});
|
|
273
|
+
if (!resolved.ok) {
|
|
274
|
+
fail(400, resolved.message);
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
rebaseSessionState(s, resolved.hash);
|
|
278
|
+
s.checkpoint = createDurableCheckpoint({
|
|
279
|
+
manifest: resolved.manifest,
|
|
280
|
+
sessionId: id,
|
|
281
|
+
turnId: s.activeTurnId,
|
|
282
|
+
input: "content" in command
|
|
283
|
+
? command.content
|
|
284
|
+
: typeof command.data === "string"
|
|
285
|
+
? command.data
|
|
286
|
+
: JSON.stringify(command.data),
|
|
287
|
+
state: s.state,
|
|
288
|
+
info: s.info,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
if (s.status !== "paused" || !s.state || !s.activeTurnId)
|
|
295
|
+
fail(409, "Session is not awaiting a response");
|
|
296
|
+
const pending = s.state.plan?.calls ?? [];
|
|
297
|
+
if (!pending.some((c) => c.status === "interaction" &&
|
|
298
|
+
c.interaction?.id === command.interactionId &&
|
|
299
|
+
(command.type === "approve") ===
|
|
300
|
+
(c.interaction?.kind === "approval")))
|
|
301
|
+
fail(409, "Unknown interaction");
|
|
302
|
+
const input = command.type === "approve"
|
|
303
|
+
? {
|
|
304
|
+
kind: "approve",
|
|
305
|
+
interactionId: command.interactionId,
|
|
306
|
+
approved: command.approved,
|
|
307
|
+
}
|
|
308
|
+
: {
|
|
309
|
+
kind: "respond",
|
|
310
|
+
interactionId: command.interactionId,
|
|
311
|
+
value: command.value,
|
|
312
|
+
};
|
|
313
|
+
if (isWorkflowManifest(s.manifest)) {
|
|
314
|
+
// Approvals owned by linked agent sessions must be answered there (WF-R52).
|
|
315
|
+
const conflict = await foreignInteractionConflict({
|
|
316
|
+
t,
|
|
317
|
+
workflowSessionId: id,
|
|
318
|
+
interactionId: command.interactionId,
|
|
319
|
+
});
|
|
320
|
+
if (conflict)
|
|
321
|
+
fail(409, conflict.message);
|
|
322
|
+
// Workflow-owned interactions (tool-node / verify) resume on this session once
|
|
323
|
+
// the flow engine supports pause segments; tracer root Loop has none yet.
|
|
324
|
+
fail(409, "Workflow sessions do not accept approve/respond on the root without a pending interaction");
|
|
325
|
+
}
|
|
326
|
+
s.checkpoint = createDurableCheckpoint({
|
|
327
|
+
manifest: turnManifestOf(s),
|
|
328
|
+
sessionId: id,
|
|
329
|
+
turnId: s.activeTurnId,
|
|
330
|
+
input: input,
|
|
331
|
+
state: s.state,
|
|
332
|
+
info: s.info,
|
|
333
|
+
segment: (s.checkpoint?.segment ?? 0) + 1,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
await t.put("checkpoints", JSON.stringify([id, s.activeTurnId, s.checkpoint.segment]), { checkpoint: s.checkpoint, status: "runnable" });
|
|
337
|
+
s.status = "runnable";
|
|
338
|
+
s.waits = undefined;
|
|
339
|
+
const commandWake = {
|
|
340
|
+
reason: command.type,
|
|
341
|
+
dedupeKey: `${command.type}:${s.activeTurnId}:${s.checkpoint.segment}`,
|
|
342
|
+
};
|
|
343
|
+
t.afterCommit(() => ctx.wake(id, commandWake));
|
|
344
|
+
event = await t.event(id, s.activeTurnId, `command.${command.type}`, command);
|
|
345
|
+
}
|
|
346
|
+
await t.put("sessions", id, s);
|
|
347
|
+
// Direct cancel of a linked agent fails that node with agent.cancelled (WF-R54). The agent
|
|
348
|
+
// session is locked above, before wakeLinkedWorkflow locks its workflow.
|
|
349
|
+
if (command.type === "cancel")
|
|
350
|
+
await wakeLinkedWorkflow({
|
|
351
|
+
t,
|
|
352
|
+
agentSessionId: id,
|
|
353
|
+
turnId: cancelledTurnId,
|
|
354
|
+
cancelled: true,
|
|
355
|
+
error: "Agent turn was cancelled",
|
|
356
|
+
schedule: ctx.wake,
|
|
357
|
+
});
|
|
358
|
+
const response = {
|
|
359
|
+
status: "accepted",
|
|
360
|
+
turnId: s.activeTurnId,
|
|
361
|
+
cursor: event.cursor,
|
|
362
|
+
requestId: command.requestId,
|
|
363
|
+
};
|
|
364
|
+
await t.put("commands", key, { command, response });
|
|
365
|
+
return response;
|
|
366
|
+
});
|
|
367
|
+
if (input.type === "cancel")
|
|
368
|
+
ctx.abortLocal(id);
|
|
369
|
+
// Cascade cancel linked agents deepest-first under existing fencing (WF-R53).
|
|
370
|
+
for (const agentId of cascadeCancelIds) {
|
|
371
|
+
try {
|
|
372
|
+
await command(ctx, agentId, {
|
|
373
|
+
type: "cancel",
|
|
374
|
+
requestId: `flow-cascade-${id}-${agentId}`,
|
|
375
|
+
idempotencyKey: `flow-cascade-cancel:${id}:${agentId}`,
|
|
376
|
+
reason: "workflow cancelled",
|
|
377
|
+
}, scope);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
/* agent may already be terminal */
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return response;
|
|
384
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared Tenant context: every Tenant module function takes a `TenantContext` instead of
|
|
3
|
+
* calling methods on the composition root. It carries the stores and services and the
|
|
4
|
+
* in-process work and live-stream state.
|
|
5
|
+
*
|
|
6
|
+
* Business code changes state only inside `ctx.store.tx(async (t) => …)` and follows the
|
|
7
|
+
* seam rules: events through `t.event(...)` (the relay appends them to Durable Streams after
|
|
8
|
+
* commit), executor wakes through `t.signalWork()`, and advances through
|
|
9
|
+
* `t.afterCommit(() => ctx.wake(id, { reason, dedupeKey }))`. It never publishes or notifies
|
|
10
|
+
* itself; `runtime.ts` wires the streams with `wireStreams()`. No external I/O runs inside a
|
|
11
|
+
* tx.
|
|
12
|
+
*
|
|
13
|
+
* `wake` goes to `DurableExecution.wake`, which calls the Tenant's `advance` (`advance.ts`)
|
|
14
|
+
* under ownership (§10.6); `abortLocal` aborts an advance running on this process.
|
|
15
|
+
*/
|
|
16
|
+
import type { TenantEnvelope } from "@nylorun/core/contracts";
|
|
17
|
+
import type { DurableCheckpoint, FlowCheckpoint } from "@nylorun/harness/run";
|
|
18
|
+
import type { AgentManifest, JsonValue } from "@nylorun/core/define";
|
|
19
|
+
import type { CredentialSelection } from "@nylorun/core/contracts";
|
|
20
|
+
import type { SessionStore, Tx } from "../store/types.js";
|
|
21
|
+
import type { ExecutorRecord, ExecutorRegistry } from "../core/executors.js";
|
|
22
|
+
import type { FlowLimits } from "../core/limits.js";
|
|
23
|
+
import type { ModelProvider } from "../core/provider.js";
|
|
24
|
+
import type { VaultService } from "../vault/service.js";
|
|
25
|
+
import type { McpPool } from "../mcp/pool.js";
|
|
26
|
+
import type { McpDiagnostic, McpSnapshot } from "../mcp/snapshot.js";
|
|
27
|
+
import type { SandboxManager } from "../sandbox/manager.js";
|
|
28
|
+
import type { TenantConfig } from "./types.js";
|
|
29
|
+
import type { LiveHub } from "./live.js";
|
|
30
|
+
import type { StuckInvocation, Wake } from "../execution/types.js";
|
|
31
|
+
import type { WorkState } from "./scheduler.js";
|
|
32
|
+
export interface Session {
|
|
33
|
+
id: string;
|
|
34
|
+
agentId: string;
|
|
35
|
+
ownerUserId: string;
|
|
36
|
+
/** Session pin — the manifest the session was created with. */
|
|
37
|
+
manifest: any;
|
|
38
|
+
manifestHash: string;
|
|
39
|
+
/** Validated turn-manifest variants, keyed by hash (loops.md §4.2). */
|
|
40
|
+
variants?: Record<string, AgentManifest>;
|
|
41
|
+
implementationVersion: string;
|
|
42
|
+
info?: any;
|
|
43
|
+
status: string;
|
|
44
|
+
activeTurnId: string | null;
|
|
45
|
+
checkpoint?: DurableCheckpoint | FlowCheckpoint;
|
|
46
|
+
state?: any;
|
|
47
|
+
turnStartState?: any;
|
|
48
|
+
waits?: unknown;
|
|
49
|
+
error?: string;
|
|
50
|
+
/** Last completed turn output (for linked agent → workflow settle). */
|
|
51
|
+
lastOutput?: JsonValue;
|
|
52
|
+
/**
|
|
53
|
+
* The last turn that ended (completed, failed or cancelled): the turn `status`, `lastOutput`
|
|
54
|
+
* and `error` describe while no turn is active. A linked `agent` effect settles only from the
|
|
55
|
+
* turn it started (`linkedTurnEnd` in `core/flow-host.ts`).
|
|
56
|
+
*/
|
|
57
|
+
lastTurnId?: string;
|
|
58
|
+
creation: unknown;
|
|
59
|
+
vaultIds?: readonly string[];
|
|
60
|
+
credentialSelections?: readonly CredentialSelection[];
|
|
61
|
+
pluginRoots?: Readonly<Record<string, string>>;
|
|
62
|
+
mcpSnapshot?: McpSnapshot;
|
|
63
|
+
mcpDiagnostics?: readonly McpDiagnostic[];
|
|
64
|
+
/** Session id that keys the shared sandbox; absent means this session owns it. */
|
|
65
|
+
sandboxOwnerId?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The incarnation naming this session's event stream (`sessions/<id>/<incarnation>`), set at
|
|
68
|
+
* creation and never changed. Absent only on sessions created before incarnations.
|
|
69
|
+
*/
|
|
70
|
+
streamIncarnation?: string;
|
|
71
|
+
}
|
|
72
|
+
export type AuthScope = {
|
|
73
|
+
kind: "application";
|
|
74
|
+
principalId: string;
|
|
75
|
+
} | {
|
|
76
|
+
kind: "executor";
|
|
77
|
+
executor: ExecutorRecord;
|
|
78
|
+
};
|
|
79
|
+
export interface TenantContext {
|
|
80
|
+
readonly config: TenantConfig;
|
|
81
|
+
readonly envelope: TenantEnvelope;
|
|
82
|
+
readonly store: SessionStore;
|
|
83
|
+
readonly vault: VaultService;
|
|
84
|
+
readonly registry: ExecutorRegistry;
|
|
85
|
+
readonly mcp: McpPool;
|
|
86
|
+
readonly sandbox: SandboxManager;
|
|
87
|
+
readonly flowLimits: FlowLimits;
|
|
88
|
+
readonly modelProvider: ModelProvider;
|
|
89
|
+
/** True when the model comes from the Tenant vault selection (pi-ai adapter). */
|
|
90
|
+
readonly useVaultModel: boolean;
|
|
91
|
+
/** Set by drain/close; reset clears it again. Stops new advances. */
|
|
92
|
+
closing: boolean;
|
|
93
|
+
/** Set once close has finished releasing resources. */
|
|
94
|
+
closed: boolean;
|
|
95
|
+
/** Advances running on this process, for `abortLocal`, drain and close. */
|
|
96
|
+
readonly work: WorkState;
|
|
97
|
+
/** Live delivery over Durable Streams: session feeds, executor streams, the streams wiring. */
|
|
98
|
+
readonly live: LiveHub;
|
|
99
|
+
/** The Worker id this process writes as session `owner` (§10.6). */
|
|
100
|
+
readonly workerId: string;
|
|
101
|
+
/** How long an advance's ownership lease lasts; the heartbeat renews it. */
|
|
102
|
+
readonly ownerLeaseMs: number;
|
|
103
|
+
/**
|
|
104
|
+
* Seam: request an advance of a session (`DurableExecution.wake`). Call it from
|
|
105
|
+
* `t.afterCommit`, never inside a tx. Dropped while the Tenant is closing; the sweep
|
|
106
|
+
* re-wakes anything left runnable.
|
|
107
|
+
*/
|
|
108
|
+
wake(sessionId: string, wake: Wake): Promise<void>;
|
|
109
|
+
/** Seam: abort the advance of a session running in this process, if any. */
|
|
110
|
+
abortLocal(sessionId: string): void;
|
|
111
|
+
/** This Tenant's execution invocations that need an operator, for Tenant status. */
|
|
112
|
+
readonly stuckInvocations?: () => Promise<StuckInvocation[]>;
|
|
113
|
+
/** Callbacks the Tenant sweep runs after its own steps (`sweep.ts`). */
|
|
114
|
+
readonly sweepHooks: ReadonlySet<() => Promise<void>>;
|
|
115
|
+
/** Adds a sweep callback (the outbox drain, Wave 2 / Y). Returns a function that removes it. */
|
|
116
|
+
onSweep(hook: () => Promise<void>): () => void;
|
|
117
|
+
}
|
|
118
|
+
/** The session, or a 404. Reads without locking. */
|
|
119
|
+
export declare function sessionOf(t: Tx, id: string): Promise<Session>;
|
|
120
|
+
/** Locks the session for the rest of the transaction and returns it, or a 404. */
|
|
121
|
+
export declare function lockedSession(t: Tx, id: string): Promise<Session>;
|
|
122
|
+
/** An advance's hold on its session (§10.6): every write the advance makes presents `epoch`. */
|
|
123
|
+
export interface Lease {
|
|
124
|
+
readonly sessionId: string;
|
|
125
|
+
readonly owner: string;
|
|
126
|
+
readonly epoch: number;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Locks `id` for the advance holding `lease`. The advance's own session is epoch-checked: a
|
|
130
|
+
* mismatch (or a deleted session) throws `ownership.lost` before anything is written. Any
|
|
131
|
+
* other session is only locked (or a 404).
|
|
132
|
+
*/
|
|
133
|
+
export declare function ownedSession(t: Tx, lease: Lease, id: string): Promise<Session>;
|
|
134
|
+
/** The session read in its own transaction, or a 404. */
|
|
135
|
+
export declare function loadSession(ctx: TenantContext, id: string): Promise<Session>;
|
|
136
|
+
/**
|
|
137
|
+
* A synchronous lookup over `start` and every session its `sandboxOwnerId` chain names, for
|
|
138
|
+
* the sandbox-sharing helpers, which walk that chain synchronously.
|
|
139
|
+
*/
|
|
140
|
+
export declare function sandboxLookup(t: Tx, start: string | undefined): Promise<(id: string) => Session | undefined>;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { fail } from "./http.js";
|
|
2
|
+
/** The session, or a 404. Reads without locking. */
|
|
3
|
+
export async function sessionOf(t, id) {
|
|
4
|
+
return ((await t.get("sessions", id)) ??
|
|
5
|
+
fail(404, "Session not found"));
|
|
6
|
+
}
|
|
7
|
+
/** Locks the session for the rest of the transaction and returns it, or a 404. */
|
|
8
|
+
export async function lockedSession(t, id) {
|
|
9
|
+
return ((await t.lockSession(id)) ?? fail(404, "Session not found"));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Locks `id` for the advance holding `lease`. The advance's own session is epoch-checked: a
|
|
13
|
+
* mismatch (or a deleted session) throws `ownership.lost` before anything is written. Any
|
|
14
|
+
* other session is only locked (or a 404).
|
|
15
|
+
*/
|
|
16
|
+
export async function ownedSession(t, lease, id) {
|
|
17
|
+
if (id === lease.sessionId)
|
|
18
|
+
return t.assertEpoch(id, lease.epoch);
|
|
19
|
+
return lockedSession(t, id);
|
|
20
|
+
}
|
|
21
|
+
/** The session read in its own transaction, or a 404. */
|
|
22
|
+
export function loadSession(ctx, id) {
|
|
23
|
+
return ctx.store.tx((t) => sessionOf(t, id));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* A synchronous lookup over `start` and every session its `sandboxOwnerId` chain names, for
|
|
27
|
+
* the sandbox-sharing helpers, which walk that chain synchronously.
|
|
28
|
+
*/
|
|
29
|
+
export async function sandboxLookup(t, start) {
|
|
30
|
+
const found = new Map();
|
|
31
|
+
let id = start;
|
|
32
|
+
while (id && !found.has(id)) {
|
|
33
|
+
const session = await t.get("sessions", id);
|
|
34
|
+
if (!session)
|
|
35
|
+
break;
|
|
36
|
+
found.set(id, session);
|
|
37
|
+
id = session.sandboxOwnerId;
|
|
38
|
+
}
|
|
39
|
+
return (sid) => found.get(sid);
|
|
40
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { EffectResolution, HostEffect } from "@nylorun/harness/run";
|
|
2
|
+
import type { AuthorizeResult } from "../vault/service.js";
|
|
3
|
+
import { type Lease, type TenantContext } from "./context.js";
|
|
4
|
+
import type { ModelProvider } from "../core/provider.js";
|
|
5
|
+
/** What one advance's segment decides for all its effects. */
|
|
6
|
+
export interface SegmentOptions {
|
|
7
|
+
/** Overrides the Tenant's model (the fixture-model Tenant setting, `model-setting.ts`). */
|
|
8
|
+
model?: ModelProvider;
|
|
9
|
+
}
|
|
10
|
+
export declare function invokeModel(ctx: TenantContext, request: HostEffect, signal: AbortSignal, model?: ModelProvider): Promise<unknown>;
|
|
11
|
+
export declare function resolveEffect(ctx: TenantContext, request: HostEffect, signal: AbortSignal, lease: Lease, segment?: SegmentOptions): Promise<EffectResolution>;
|
|
12
|
+
/** Journal and dispatch a new flow effect (agent / tool node / fn / verify). */
|
|
13
|
+
export declare function resolveNewFlowEffect(ctx: TenantContext, request: HostEffect, signal: AbortSignal, lease: Lease): Promise<EffectResolution>;
|
|
14
|
+
/** Discover the session's MCP tools once, or reconnect and refresh diagnostics. */
|
|
15
|
+
export declare function prepareMcp(ctx: TenantContext, lease: Lease, signal: AbortSignal): Promise<void>;
|
|
16
|
+
/** Vault authorization for an MCP server request made on behalf of a session. */
|
|
17
|
+
export declare function authorize(ctx: TenantContext, sessionId: string, request: {
|
|
18
|
+
url: string;
|
|
19
|
+
serverName?: string;
|
|
20
|
+
}): Promise<AuthorizeResult>;
|