@intx/hub-sessions 0.2.2 → 0.3.0
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/README.md +3 -5
- package/dist/agent-repo.d.ts +9 -5
- package/dist/agent-repo.js +2 -2
- package/dist/agent-state-kind.js +4 -0
- package/dist/asset-service.d.ts +1 -20
- package/dist/asset-service.js +9 -91
- package/dist/committed-source-tree.d.ts +10 -0
- package/dist/committed-source-tree.js +35 -0
- package/dist/credential-push.d.ts +7 -6
- package/dist/credential-push.js +42 -18
- package/dist/event-collector-registry.d.ts +1 -1
- package/dist/event-collector-registry.js +4 -4
- package/dist/event-collector.d.ts +1 -1
- package/dist/event-collector.js +10 -2
- package/dist/hub-session-lookups.d.ts +125 -7
- package/dist/hub-session-lookups.js +539 -80
- package/dist/hub-session-orchestrator.js +14 -49
- package/dist/index.d.ts +17 -8
- package/dist/index.js +14 -6
- package/dist/repo-store/index.d.ts +1 -1
- package/dist/repo-store/store.d.ts +1 -1
- package/dist/repo-store/store.js +138 -1
- package/dist/repo-store/subscribe-kind.d.ts +6 -3
- package/dist/repo-store/subscribe-kind.js +42 -77
- package/dist/repo-store/types.d.ts +94 -6
- package/dist/session-service.d.ts +277 -96
- package/dist/session-service.js +741 -547
- package/dist/sidecar-allocation/contracts.d.ts +78 -0
- package/dist/sidecar-allocation/contracts.js +21 -0
- package/dist/sidecar-allocation/index.d.ts +4 -0
- package/dist/sidecar-allocation/index.js +3 -0
- package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
- package/dist/sidecar-allocation/placement-policy.js +21 -0
- package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
- package/dist/sidecar-allocation/plugin-registry.js +37 -0
- package/dist/sidecar-allocation/reconciler.d.ts +42 -0
- package/dist/sidecar-allocation/reconciler.js +431 -0
- package/dist/skill-kind.js +4 -0
- package/dist/substrate.d.ts +3 -3
- package/dist/substrate.js +1 -1
- package/dist/workflow-allocation-service.d.ts +58 -0
- package/dist/workflow-allocation-service.js +239 -0
- package/dist/workflow-closure-resolution.d.ts +106 -0
- package/dist/workflow-closure-resolution.js +123 -0
- package/dist/workflow-definition-ensure.d.ts +24 -0
- package/dist/workflow-definition-ensure.js +75 -0
- package/dist/workflow-dispatch-service.d.ts +40 -0
- package/dist/workflow-dispatch-service.js +146 -0
- package/dist/workflow-dispatch-settlement.d.ts +29 -0
- package/dist/workflow-dispatch-settlement.js +140 -0
- package/dist/workflow-kind.d.ts +17 -1
- package/dist/workflow-kind.js +127 -80
- package/dist/workflow-probe-gate.d.ts +214 -0
- package/dist/workflow-probe-gate.js +207 -0
- package/dist/workflow-run-kind.d.ts +128 -14
- package/dist/workflow-run-kind.js +353 -83
- package/dist/workflow-run-reader.d.ts +1 -1
- package/dist/workflow-run-reader.js +3 -7
- package/dist/workflow-run-restore.d.ts +15 -0
- package/dist/workflow-run-restore.js +26 -0
- package/dist/workflow-source-closure.d.ts +35 -0
- package/dist/workflow-source-closure.js +342 -0
- package/dist/ws/index.d.ts +3 -3
- package/dist/ws/index.js +1 -1
- package/dist/ws/sidecar-events.d.ts +100 -12
- package/dist/ws/sidecar-events.js +2 -0
- package/dist/ws/sidecar-handler.d.ts +128 -7
- package/dist/ws/sidecar-handler.js +1069 -135
- package/dist/ws/sidecar-token-authenticator.d.ts +3 -1
- package/dist/ws/sidecar-token-authenticator.js +64 -7
- package/package.json +14 -13
- package/dist/available-skills-stanza.d.ts +0 -21
- package/dist/available-skills-stanza.js +0 -32
|
@@ -5,92 +5,99 @@
|
|
|
5
5
|
// Each lookup is a stateless DB or repo call. They are gathered into a
|
|
6
6
|
// single struct that the hub app passes to `createSidecarRouter` as
|
|
7
7
|
// `lookups`.
|
|
8
|
-
import { eq, and, isNull } from "drizzle-orm";
|
|
9
|
-
import {
|
|
8
|
+
import { eq, and, asc, inArray, isNull } from "drizzle-orm";
|
|
9
|
+
import { createApprovalStore, createSignalCorrelationStore, createWorkflowRunDispatchStore, createWorkflowRunStore, } from "@intx/db";
|
|
10
|
+
import { agentSession, liveWorkflowRunStatuses, principal, sessionMail, sidecarAllocation, workflowRun, } from "@intx/db/schema";
|
|
10
11
|
import { getLogger } from "@intx/log";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
12
|
+
import { parseRunAddress, signalName } from "@intx/types";
|
|
13
|
+
import { SignalDeliverFrame } from "@intx/types/sidecar";
|
|
14
|
+
import { deriveWorkflowRunRepoId } from "@intx/workflow-deploy";
|
|
13
15
|
import { generateId } from "@intx/hub-common";
|
|
16
|
+
import { listAcceptedWorkflowDispatches, listConsumedWorkflowDispatches, } from "./workflow-dispatch-settlement.js";
|
|
17
|
+
import { readCommittedWorkflowRunLifecycle } from "./workflow-run-kind.js";
|
|
14
18
|
const logger = getLogger(["hub", "lookups"]);
|
|
15
19
|
export function createHubSessionLookups(deps) {
|
|
16
20
|
const { db, agentRepoStore } = deps;
|
|
21
|
+
const signalCorrelationStore = createSignalCorrelationStore(db);
|
|
22
|
+
const approvalStore = createApprovalStore(db);
|
|
23
|
+
const workflowRunStore = createWorkflowRunStore(db);
|
|
24
|
+
const workflowRunDispatchStore = createWorkflowRunDispatchStore(db);
|
|
17
25
|
return {
|
|
18
26
|
async lookupPublicKey(agentAddress) {
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// row,
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
// publicKey (deployed but not yet acked, or pre-migration) or an
|
|
30
|
-
// absent row returns null -- the challenge fails closed and the
|
|
31
|
-
// address stays unrouted rather than routing without ownership
|
|
32
|
-
// proof.
|
|
33
|
-
const row = await db
|
|
34
|
-
.select({ publicKey: workflowDeployment.publicKey })
|
|
35
|
-
.from(workflowDeployment)
|
|
36
|
-
.where(and(eq(workflowDeployment.address, agentAddress), eq(workflowDeployment.status, "deployed")))
|
|
37
|
-
.limit(1)
|
|
38
|
-
.then((rows) => rows[0]);
|
|
39
|
-
return row?.publicKey ?? null;
|
|
40
|
-
}
|
|
27
|
+
// Every routable address names one workflow run, whose key lives on its
|
|
28
|
+
// single self-anchored workflow_run row, keyed by address. Read the key
|
|
29
|
+
// off that row, gated on a live run (born "deployed", "running" after its
|
|
30
|
+
// first trigger) so a decommissioned deployment's key can no longer
|
|
31
|
+
// satisfy a challenge. The "deployed" arm is load-bearing: the reconnect
|
|
32
|
+
// ownership challenge fires in the deploy->first-trigger window, so a
|
|
33
|
+
// "running"-only gate would fail every such challenge closed. A missing
|
|
34
|
+
// row or a null publicKey (live but not yet acked) returns null so the
|
|
35
|
+
// reconnect challenge fails closed and the address stays unrouted rather
|
|
36
|
+
// than routing without ownership proof.
|
|
41
37
|
const row = await db
|
|
42
|
-
.select({ publicKey:
|
|
43
|
-
.from(
|
|
44
|
-
.where(and(eq(
|
|
38
|
+
.select({ publicKey: workflowRun.publicKey })
|
|
39
|
+
.from(workflowRun)
|
|
40
|
+
.where(and(eq(workflowRun.address, agentAddress), inArray(workflowRun.status, [...liveWorkflowRunStatuses])))
|
|
45
41
|
.limit(1)
|
|
46
42
|
.then((rows) => rows[0]);
|
|
47
43
|
return row?.publicKey ?? null;
|
|
48
44
|
},
|
|
49
|
-
async lookupDeployRef(
|
|
50
|
-
|
|
51
|
-
|
|
45
|
+
async lookupDeployRef() {
|
|
46
|
+
// A workflow run is a supervised workflow-process pinned forever like a
|
|
47
|
+
// native deployment: it keeps its deploy-time definition and never
|
|
48
|
+
// reconciles, so no address enrolls in the reconnect deploy-ref catch-up.
|
|
49
|
+
return null;
|
|
52
50
|
},
|
|
53
51
|
async persistMail({ senderAddress, recipients, raw }) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
// The sender and recipients are run addresses, each backed by its
|
|
53
|
+
// self-anchored workflow_run; resolve each through the resolver. A mail
|
|
54
|
+
// record's `runId` is always null for a run -- it keys on the run's
|
|
55
|
+
// session instead.
|
|
56
|
+
const sender = await resolveRoutableAddress(db, senderAddress);
|
|
57
|
+
if (sender === undefined) {
|
|
58
|
+
throw new Error(`No active endpoint found for sender address "${senderAddress}"`);
|
|
59
|
+
}
|
|
60
|
+
if (sender.sessionId === null) {
|
|
61
|
+
throw new Error(`Endpoint ${sender.id} has no session for address "${senderAddress}"`);
|
|
57
62
|
}
|
|
58
63
|
const createdAt = new Date();
|
|
64
|
+
const senderRunId = null;
|
|
59
65
|
// Outbound record on the sender's session.
|
|
60
66
|
const outboundId = generateId("sessionMail");
|
|
61
67
|
const outboundRecord = {
|
|
62
68
|
id: outboundId,
|
|
63
|
-
sessionId:
|
|
64
|
-
|
|
65
|
-
tenantId:
|
|
69
|
+
sessionId: sender.sessionId,
|
|
70
|
+
runId: senderRunId,
|
|
71
|
+
tenantId: sender.tenantId,
|
|
66
72
|
direction: "outbound",
|
|
67
73
|
status: "delivered",
|
|
68
74
|
raw,
|
|
69
75
|
createdAt,
|
|
70
76
|
};
|
|
71
|
-
// Inbound records for each recipient that
|
|
72
|
-
//
|
|
73
|
-
// user addresses) are skipped.
|
|
77
|
+
// Inbound records for each recipient that is a live endpoint.
|
|
78
|
+
// Recipients that are not (e.g. human user addresses) are skipped.
|
|
74
79
|
const recipientResults = await Promise.all(recipients.map(async (addr) => {
|
|
75
|
-
const
|
|
76
|
-
if (
|
|
80
|
+
const endpoint = await resolveRoutableAddress(db, addr);
|
|
81
|
+
if (endpoint === undefined) {
|
|
77
82
|
return null;
|
|
78
83
|
}
|
|
79
|
-
if (
|
|
80
|
-
logger.warn `Active
|
|
84
|
+
if (endpoint.sessionId === null) {
|
|
85
|
+
logger.warn `Active endpoint ${endpoint.id} for "${addr}" has no session; skipping inbound record`;
|
|
81
86
|
return null;
|
|
82
87
|
}
|
|
83
|
-
return { addr,
|
|
88
|
+
return { addr, endpoint, sessionId: endpoint.sessionId };
|
|
84
89
|
}));
|
|
85
|
-
const
|
|
86
|
-
const inboundEntries =
|
|
90
|
+
const recipientEndpoints = recipientResults.filter((r) => r !== null);
|
|
91
|
+
const inboundEntries = recipientEndpoints.map(({ addr, endpoint, sessionId }) => {
|
|
87
92
|
const id = generateId("sessionMail");
|
|
93
|
+
// A folded run is not an instance, so its mail records no runId.
|
|
94
|
+
const runId = null;
|
|
88
95
|
return {
|
|
89
96
|
record: {
|
|
90
97
|
id,
|
|
91
98
|
sessionId,
|
|
92
|
-
|
|
93
|
-
tenantId:
|
|
99
|
+
runId,
|
|
100
|
+
tenantId: endpoint.tenantId,
|
|
94
101
|
direction: "inbound",
|
|
95
102
|
status: "delivered",
|
|
96
103
|
raw,
|
|
@@ -99,7 +106,7 @@ export function createHubSessionLookups(deps) {
|
|
|
99
106
|
result: {
|
|
100
107
|
id,
|
|
101
108
|
direction: "inbound",
|
|
102
|
-
|
|
109
|
+
runId,
|
|
103
110
|
address: addr,
|
|
104
111
|
createdAt,
|
|
105
112
|
},
|
|
@@ -112,13 +119,111 @@ export function createHubSessionLookups(deps) {
|
|
|
112
119
|
{
|
|
113
120
|
id: outboundId,
|
|
114
121
|
direction: "outbound",
|
|
115
|
-
|
|
116
|
-
address:
|
|
122
|
+
runId: senderRunId,
|
|
123
|
+
address: sender.address,
|
|
117
124
|
createdAt,
|
|
118
125
|
},
|
|
119
126
|
...inboundEntries.map((e) => e.result),
|
|
120
127
|
];
|
|
121
128
|
},
|
|
129
|
+
async registerSignalCorrelation({ correlationId, runId, anchorRunId, agentAddress, kind, approvalSnapshot, }) {
|
|
130
|
+
// Resolve tenancy and co-write both rows in one transaction so a resolver
|
|
131
|
+
// never sees a correlation without its approval or vice versa. Both
|
|
132
|
+
// inserts are idempotent on their dedup key (the signal_correlation
|
|
133
|
+
// primary key and the approval's unique correlationId), so a redelivered
|
|
134
|
+
// frame -- sidecar reconnect, workflow-log replay, supervisor restart
|
|
135
|
+
// re-emitting -- is a no-op rather than a unique-violation. `timeoutAt` is
|
|
136
|
+
// null: an agent-step suspend holds indefinitely (`parkOnSignal` is called
|
|
137
|
+
// with no timeout), so no deadline reaches this co-write.
|
|
138
|
+
await db.transaction(async (tx) => {
|
|
139
|
+
// Resolve tenancy and the run's definition from the deployment's anchor
|
|
140
|
+
// run -- the workflow_run whose id is the deployment id, which the
|
|
141
|
+
// address names. The anchor is the tenancy origin every approval needs
|
|
142
|
+
// (an approval has no agent_instance/agent/principal referent). The
|
|
143
|
+
// lookup keys off `address` (the field the wire layer's ownership gate
|
|
144
|
+
// authorized), not the frame's `anchorRunId`: that is the workflow-run
|
|
145
|
+
// repo slug the supervisor derives from the address
|
|
146
|
+
// (`deriveWorkflowRunRepoId`), cross-checked below against the slug
|
|
147
|
+
// re-derived from `agentAddress` rather than against the row id. A
|
|
148
|
+
// mismatch fails loud instead of silently writing an inconsistent pair.
|
|
149
|
+
// The FK columns take the anchor run's id (= the deployment id), which
|
|
150
|
+
// is what `signal_correlation.anchor_run_id` and `approval.anchor_run_id`
|
|
151
|
+
// reference.
|
|
152
|
+
//
|
|
153
|
+
// The resolution takes a `FOR UPDATE` row lock and runs inside the
|
|
154
|
+
// co-write transaction, gated on a live anchor run ("deployed" or
|
|
155
|
+
// "running"), so the liveness check and the inserts are atomic against a
|
|
156
|
+
// concurrent teardown that flips the anchor run terminal. The lock order
|
|
157
|
+
// is workflow_run before signal_correlation and approval; a teardown
|
|
158
|
+
// path must take the anchor-run lock before touching those rows to keep
|
|
159
|
+
// the ordering acyclic.
|
|
160
|
+
const anchor = await tx
|
|
161
|
+
.select({
|
|
162
|
+
id: workflowRun.id,
|
|
163
|
+
tenantId: workflowRun.tenantId,
|
|
164
|
+
definitionId: workflowRun.definitionId,
|
|
165
|
+
})
|
|
166
|
+
.from(workflowRun)
|
|
167
|
+
.where(and(eq(workflowRun.address, agentAddress), inArray(workflowRun.status, [...liveWorkflowRunStatuses])))
|
|
168
|
+
.for("update")
|
|
169
|
+
.limit(1)
|
|
170
|
+
.then((rows) => rows[0]);
|
|
171
|
+
if (anchor === undefined) {
|
|
172
|
+
throw new Error(`No live workflow run for address "${agentAddress}"; cannot register signal correlation ${correlationId}`);
|
|
173
|
+
}
|
|
174
|
+
const addressSlug = deriveWorkflowRunRepoId(agentAddress);
|
|
175
|
+
if (addressSlug !== anchorRunId) {
|
|
176
|
+
throw new Error(`Anchor run id mismatch registering signal correlation ${correlationId}: frame claims "${anchorRunId}" but address "${agentAddress}" derives the workflow-run repo slug "${addressSlug}"`);
|
|
177
|
+
}
|
|
178
|
+
const tenantId = anchor.tenantId;
|
|
179
|
+
const definitionId = anchor.definitionId;
|
|
180
|
+
// Lazily anchor the run before its correlation and approval reference
|
|
181
|
+
// it. A workflow-spawned internal run never crosses the external
|
|
182
|
+
// trigger route that mints a run principal, so its run row would
|
|
183
|
+
// otherwise not exist; ensure it here so the co-written rows have a
|
|
184
|
+
// referent. The principal is null: an internal run inherits its
|
|
185
|
+
// deployment's grants and has no principal of its own. The insert is
|
|
186
|
+
// idempotent on the run id, so a redelivered register frame -- the same
|
|
187
|
+
// redelivery the co-writes below tolerate -- is a no-op.
|
|
188
|
+
await workflowRunStore.createIfAbsent({
|
|
189
|
+
id: runId,
|
|
190
|
+
anchorRunId: anchor.id,
|
|
191
|
+
definitionId,
|
|
192
|
+
tenantId,
|
|
193
|
+
principalId: null,
|
|
194
|
+
status: "running",
|
|
195
|
+
}, tx);
|
|
196
|
+
await signalCorrelationStore.registerIfAbsent({
|
|
197
|
+
correlationId,
|
|
198
|
+
tenantId,
|
|
199
|
+
anchorRunId: anchor.id,
|
|
200
|
+
agentAddress,
|
|
201
|
+
runId,
|
|
202
|
+
signalName: signalName(correlationId),
|
|
203
|
+
kind,
|
|
204
|
+
}, tx);
|
|
205
|
+
await approvalStore.createIfAbsent({
|
|
206
|
+
id: generateId("approval"),
|
|
207
|
+
tenantId,
|
|
208
|
+
anchorRunId: anchor.id,
|
|
209
|
+
runId,
|
|
210
|
+
agentAddress,
|
|
211
|
+
correlationId,
|
|
212
|
+
status: "pending",
|
|
213
|
+
// The register frame guarantees the snapshot (the ask rail is its
|
|
214
|
+
// only producer), so the approver-facing columns are always
|
|
215
|
+
// populated -- never null on this path.
|
|
216
|
+
toolDefinition: {
|
|
217
|
+
name: approvalSnapshot.name,
|
|
218
|
+
description: approvalSnapshot.description,
|
|
219
|
+
inputSchema: approvalSnapshot.inputSchema,
|
|
220
|
+
},
|
|
221
|
+
toolArguments: approvalSnapshot.arguments,
|
|
222
|
+
scope: null,
|
|
223
|
+
timeoutAt: null,
|
|
224
|
+
}, tx);
|
|
225
|
+
});
|
|
226
|
+
},
|
|
122
227
|
async receiveAgentStatePack(repoId, pack, ref, commitSha) {
|
|
123
228
|
if (repoId.kind !== "agent-state") {
|
|
124
229
|
throw new Error(`hub-session lookups receiveAgentStatePack received unsupported repo kind ${JSON.stringify(repoId.kind)}`);
|
|
@@ -150,55 +255,409 @@ export function createHubSessionLookups(deps) {
|
|
|
150
255
|
}
|
|
151
256
|
return { accepted: true };
|
|
152
257
|
},
|
|
153
|
-
async receiveWorkflowRunPack(repoId, pack, ref, commitSha) {
|
|
258
|
+
async receiveWorkflowRunPack(repoId, pack, ref, commitSha, source) {
|
|
154
259
|
if (repoId.kind !== "workflow-run") {
|
|
155
260
|
throw new Error(`hub-session lookups receiveWorkflowRunPack received unsupported repo kind ${JSON.stringify(repoId.kind)}`);
|
|
156
261
|
}
|
|
157
|
-
const
|
|
262
|
+
const workflowRunRepoId = repoId.id;
|
|
263
|
+
if (deriveWorkflowRunRepoId(source.agentAddress) !== workflowRunRepoId) {
|
|
264
|
+
logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: source address does not own the repository`;
|
|
265
|
+
return { accepted: false, reason: "path_violation" };
|
|
266
|
+
}
|
|
267
|
+
const [anchor] = await db
|
|
268
|
+
.select({
|
|
269
|
+
id: workflowRun.id,
|
|
270
|
+
address: workflowRun.address,
|
|
271
|
+
anchorRunId: workflowRun.anchorRunId,
|
|
272
|
+
})
|
|
273
|
+
.from(workflowRun)
|
|
274
|
+
.where(and(eq(workflowRun.address, source.agentAddress), inArray(workflowRun.status, [...liveWorkflowRunStatuses])))
|
|
275
|
+
.limit(1);
|
|
276
|
+
if (anchor === undefined ||
|
|
277
|
+
anchor.anchorRunId !== anchor.id ||
|
|
278
|
+
anchor.address === null) {
|
|
279
|
+
logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: source address has no live deployment anchor`;
|
|
280
|
+
return { accepted: false, reason: "path_violation" };
|
|
281
|
+
}
|
|
282
|
+
const anchorAddress = anchor.address;
|
|
283
|
+
let newlyTerminalRuns;
|
|
158
284
|
try {
|
|
159
|
-
|
|
285
|
+
if (source.kind === "allocated") {
|
|
286
|
+
newlyTerminalRuns = await db.transaction(async (tx) => {
|
|
287
|
+
const [allocation] = await tx
|
|
288
|
+
.select()
|
|
289
|
+
.from(sidecarAllocation)
|
|
290
|
+
.where(eq(sidecarAllocation.anchorRunId, anchor.id))
|
|
291
|
+
.limit(1)
|
|
292
|
+
.for("update");
|
|
293
|
+
if (allocation === undefined ||
|
|
294
|
+
allocation.id !== source.allocationId ||
|
|
295
|
+
allocation.anchorRunId !== source.anchorRunId ||
|
|
296
|
+
source.anchorRunId !== anchor.id ||
|
|
297
|
+
allocation.status !== "allocated" ||
|
|
298
|
+
allocation.generation !== source.generation ||
|
|
299
|
+
allocation.ensureAcceptedGeneration !== source.generation) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
// Replacement advances this same row. Keep its lock until the
|
|
303
|
+
// repository ref has advanced so ownership cannot change after
|
|
304
|
+
// validation but before the old worker's pack becomes
|
|
305
|
+
// authoritative.
|
|
306
|
+
return agentRepoStore.receiveWorkflowRunPack({ kind: "workflow-run", id: workflowRunRepoId }, pack, ref, commitSha);
|
|
307
|
+
});
|
|
308
|
+
if (newlyTerminalRuns === null) {
|
|
309
|
+
logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: source connection does not own the deployment's current allocation`;
|
|
310
|
+
return { accepted: false, reason: "path_violation" };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
const allocation = await db.query.sidecarAllocation.findFirst({
|
|
315
|
+
where: eq(sidecarAllocation.anchorRunId, anchor.id),
|
|
316
|
+
});
|
|
317
|
+
if (allocation !== undefined) {
|
|
318
|
+
logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: source connection does not own the deployment's current allocation`;
|
|
319
|
+
return { accepted: false, reason: "path_violation" };
|
|
320
|
+
}
|
|
321
|
+
newlyTerminalRuns = await agentRepoStore.receiveWorkflowRunPack({ kind: "workflow-run", id: workflowRunRepoId }, pack, ref, commitSha);
|
|
322
|
+
}
|
|
160
323
|
}
|
|
161
324
|
catch (err) {
|
|
162
325
|
const msg = err instanceof Error ? err.message : String(err);
|
|
163
326
|
if (msg.startsWith("path_violation")) {
|
|
164
|
-
logger.warn `Workflow-run pack rejected for ${
|
|
327
|
+
logger.warn `Workflow-run pack rejected for ${workflowRunRepoId}: ${msg}`;
|
|
165
328
|
return { accepted: false, reason: "path_violation" };
|
|
166
329
|
}
|
|
167
|
-
// Mirror the agent-state branch's catch-all: any other failure
|
|
168
|
-
//
|
|
169
|
-
// diagnostics surfaced as Error messages, etc.) becomes
|
|
170
|
-
// structured `corrupt` rejection so the sender can re-push,
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
logger.error `Workflow-run pack receive failed for ${
|
|
330
|
+
// Mirror the agent-state branch's catch-all: any other failure from
|
|
331
|
+
// the fenced receive (transaction failures, filesystem races,
|
|
332
|
+
// kind-handler diagnostics surfaced as Error messages, etc.) becomes
|
|
333
|
+
// a structured `corrupt` rejection so the sender can re-push, and the
|
|
334
|
+
// underlying error is logged so the cause stays traceable on the hub
|
|
335
|
+
// side.
|
|
336
|
+
logger.error `Workflow-run pack receive failed for ${workflowRunRepoId}: ${msg}`;
|
|
174
337
|
return { accepted: false, reason: "corrupt" };
|
|
175
338
|
}
|
|
339
|
+
// The substrate has already durably advanced the git ref by the time it
|
|
340
|
+
// returns, so the pack is accepted regardless of what happens below. The
|
|
341
|
+
// per-run status flip and principal deactivation are a best-effort
|
|
342
|
+
// downstream side effect of that durable advance, not part of accepting
|
|
343
|
+
// the pack. A failure here leaves the run "running" in the DB with its
|
|
344
|
+
// principal still active; there is no automatic re-fire, because a
|
|
345
|
+
// redelivery of the same durable tip produces no newly-terminal signal
|
|
346
|
+
// (the substrate's per-commit walk short-circuits on an already-present
|
|
347
|
+
// tip). The failure is therefore logged at ERROR as the only record that
|
|
348
|
+
// the row needs a manual flip, and the pack verdict stays accepted so the
|
|
349
|
+
// sidecar is acked and does not wedge re-pushing a pack that already
|
|
350
|
+
// landed.
|
|
351
|
+
const now = new Date();
|
|
352
|
+
for (const { runId, status } of newlyTerminalRuns) {
|
|
353
|
+
try {
|
|
354
|
+
await db.transaction(async (tx) => {
|
|
355
|
+
const [ownedRun] = await tx
|
|
356
|
+
.select({ anchorRunId: workflowRun.anchorRunId })
|
|
357
|
+
.from(workflowRun)
|
|
358
|
+
.where(eq(workflowRun.id, runId))
|
|
359
|
+
.limit(1);
|
|
360
|
+
if (ownedRun?.anchorRunId !== anchor.id) {
|
|
361
|
+
logger.error `Ignoring terminal event for run ${runId}: it does not belong to source deployment ${anchor.id}`;
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const won = await workflowRunStore.markTerminal(runId, status, now, tx);
|
|
365
|
+
if (won === null) {
|
|
366
|
+
// No running row matched. Either the run is already terminal (a
|
|
367
|
+
// benign replay against an already-settled row) or no row exists
|
|
368
|
+
// at all -- the run reached a terminal event before its anchor
|
|
369
|
+
// committed, so its terminal state has nowhere to land. Only the
|
|
370
|
+
// second case is a defect; distinguish them and log the missing
|
|
371
|
+
// anchor loudly rather than silently treating both as done.
|
|
372
|
+
const [existing] = await tx
|
|
373
|
+
.select({ id: workflowRun.id })
|
|
374
|
+
.from(workflowRun)
|
|
375
|
+
.where(eq(workflowRun.id, runId));
|
|
376
|
+
if (existing === undefined) {
|
|
377
|
+
logger.error `Terminal event for run ${runId} (deployment ${anchor.id}, target status ${status}) has no workflow_run row; the run terminated before its anchor committed`;
|
|
378
|
+
}
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
// Deactivate the run's own principal, if it has one. Externally-
|
|
382
|
+
// triggered runs carry a principal; internal, workflow-spawned runs
|
|
383
|
+
// have `principalId = null` and inherit the deployment's grants, so
|
|
384
|
+
// there is nothing to deactivate. Deactivation is gated on winning
|
|
385
|
+
// the flip -- the single claim point -- not on the principal's own
|
|
386
|
+
// status.
|
|
387
|
+
if (won.principalId !== null) {
|
|
388
|
+
await tx
|
|
389
|
+
.update(principal)
|
|
390
|
+
.set({ status: "deactivated", updatedAt: now })
|
|
391
|
+
// The `refId` clause is a defensive mirror of the per-instance
|
|
392
|
+
// teardown in instances.ts: `won.principalId` is already this
|
|
393
|
+
// run's own principal, and `principal.id` is the primary key,
|
|
394
|
+
// so the `refId` match is belt-and-suspenders that the id we
|
|
395
|
+
// won belongs to this run.
|
|
396
|
+
.where(and(eq(principal.id, won.principalId), eq(principal.refId, runId)));
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
// Per-run isolation: a failed flip for one run must not abort the
|
|
402
|
+
// rest of the batch, and must not throw out of this method -- a throw
|
|
403
|
+
// would leave the sidecar with neither an ack nor a reject for a pack
|
|
404
|
+
// the substrate already accepted. This ERROR is the only signal that
|
|
405
|
+
// the run is stuck "running" in the DB with its principal active, so
|
|
406
|
+
// it carries enough to find and flip the row by hand.
|
|
407
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
408
|
+
logger.error `Terminal DB flip failed for run ${runId} (deployment ${anchor.id}, target status ${status}); run left running in the DB: ${msg}`;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
// A sidecar-local mail ack is only receipt. The raw trigger remains in
|
|
412
|
+
// workflow_run_dispatch until the accepted Git tip proves either that
|
|
413
|
+
// the run recorded it (RunStarted / SignalReceived), or that the
|
|
414
|
+
// supervisor consumed it with an explicit rejection. Rescan the bounded
|
|
415
|
+
// retained claim-check index after every accepted pack, and search the
|
|
416
|
+
// stable run log newest-first only for currently-unsettled ids. Settlement
|
|
417
|
+
// is idempotent, and a later pack naturally retries a transient database
|
|
418
|
+
// failure here.
|
|
419
|
+
let topLevelTerminalSettlementProjected = false;
|
|
420
|
+
try {
|
|
421
|
+
const reads = await agentRepoStore.repoStore.openCommittedReads({ kind: "hub" }, repoId, ref);
|
|
422
|
+
if (reads !== null) {
|
|
423
|
+
const unsettledDispatches = await workflowRunDispatchStore.listUnsettled(anchor.id);
|
|
424
|
+
const unsettledByMessageId = new Map(unsettledDispatches.map((dispatch) => [
|
|
425
|
+
dispatch.messageId,
|
|
426
|
+
dispatch,
|
|
427
|
+
]));
|
|
428
|
+
for (const consumed of await listConsumedWorkflowDispatches(reads)) {
|
|
429
|
+
if (consumed.address !== anchorAddress)
|
|
430
|
+
continue;
|
|
431
|
+
const persisted = unsettledByMessageId.get(consumed.messageId);
|
|
432
|
+
if (persisted?.kind !== "mail")
|
|
433
|
+
continue;
|
|
434
|
+
if (consumed.rejection === undefined) {
|
|
435
|
+
await workflowRunDispatchStore.settle(anchor.id, consumed.messageId, now);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
await workflowRunDispatchStore.fail({
|
|
439
|
+
anchorRunId: anchor.id,
|
|
440
|
+
messageId: consumed.messageId,
|
|
441
|
+
code: consumed.rejection.code,
|
|
442
|
+
message: consumed.rejection.message,
|
|
443
|
+
now,
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
unsettledByMessageId.delete(consumed.messageId);
|
|
447
|
+
}
|
|
448
|
+
// Mail is recorded on the stable deployment run, while a signal is
|
|
449
|
+
// recorded on the exact run named by its durable delivery frame.
|
|
450
|
+
// Group retained dispatches by that Git run before scanning so an
|
|
451
|
+
// internal run's SignalReceived evidence settles its own dispatch.
|
|
452
|
+
// The `runs/<runId>/` log keys on the run id (the address local
|
|
453
|
+
// part), NOT the full address: post-collapse the top-level run's id
|
|
454
|
+
// IS `anchor.id`, so mail keys on `anchor.id`; a signal keys on its
|
|
455
|
+
// frame's own run id. (The `addresses/<address>/` consumed subtree
|
|
456
|
+
// above keys on the full address -- a different subtree.)
|
|
457
|
+
const messageIdsByRun = new Map();
|
|
458
|
+
for (const dispatch of unsettledByMessageId.values()) {
|
|
459
|
+
const runId = dispatch.kind === "mail"
|
|
460
|
+
? anchor.id
|
|
461
|
+
: SignalDeliverFrame.assert(JSON.parse(new TextDecoder().decode(dispatch.rawMessage))).runId;
|
|
462
|
+
const messageIds = messageIdsByRun.get(runId) ?? new Set();
|
|
463
|
+
messageIds.add(dispatch.messageId);
|
|
464
|
+
messageIdsByRun.set(runId, messageIds);
|
|
465
|
+
}
|
|
466
|
+
for (const [runId, messageIds] of messageIdsByRun) {
|
|
467
|
+
for (const accepted of await listAcceptedWorkflowDispatches(reads, runId, messageIds)) {
|
|
468
|
+
const persisted = unsettledByMessageId.get(accepted.messageId);
|
|
469
|
+
if (persisted?.kind !== accepted.kind)
|
|
470
|
+
continue;
|
|
471
|
+
await workflowRunDispatchStore.settle(anchor.id, accepted.messageId, now);
|
|
472
|
+
unsettledByMessageId.delete(accepted.messageId);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
topLevelTerminalSettlementProjected =
|
|
476
|
+
(await readCommittedWorkflowRunLifecycle(reads, anchor.id)) ===
|
|
477
|
+
"terminal";
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
logger.error `Workflow dispatch settlement failed for ${anchor.id}; accepted Git state remains authoritative and the retained payload will be retried: ${error instanceof Error ? error.message : String(error)}`;
|
|
482
|
+
}
|
|
483
|
+
if (topLevelTerminalSettlementProjected) {
|
|
484
|
+
try {
|
|
485
|
+
await workflowRunDispatchStore.failUnsettled(anchor.id, "workflow_run_terminal", `Workflow run ${anchorAddress} is terminal and cannot accept this dispatch`, now);
|
|
486
|
+
}
|
|
487
|
+
catch (error) {
|
|
488
|
+
logger.error `Failed to close unsettled workflow dispatches for terminal run ${anchorAddress}: ${error instanceof Error ? error.message : String(error)}`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
176
491
|
return { accepted: true };
|
|
177
492
|
},
|
|
178
493
|
};
|
|
179
494
|
}
|
|
180
495
|
/**
|
|
181
|
-
* Extract the
|
|
182
|
-
* Throws on any input the `@intx/types`-owned `
|
|
183
|
-
* rejects: missing or leading `@`, empty domain, or
|
|
184
|
-
* without the canonical `
|
|
496
|
+
* Extract the run id from an `<runId>@<domain>` run address.
|
|
497
|
+
* Throws on any input the `@intx/types`-owned `parseRunAddress`
|
|
498
|
+
* rejects: missing or leading `@`, empty domain, or a run id
|
|
499
|
+
* without the canonical `run_` prefix.
|
|
185
500
|
*/
|
|
186
501
|
export function parseAgentId(agentAddress) {
|
|
187
|
-
const parsed =
|
|
502
|
+
const parsed = parseRunAddress(agentAddress);
|
|
188
503
|
if (parsed === null) {
|
|
189
|
-
throw new Error(`Invalid
|
|
504
|
+
throw new Error(`Invalid run address: "${agentAddress}"`);
|
|
505
|
+
}
|
|
506
|
+
return parsed.runId;
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Resolve a run address to the `workflow_run` endpoint backing it, keyed by
|
|
510
|
+
* the run's `address`. Every routable address names one self-anchored run --
|
|
511
|
+
* the deployment's anchor -- so this resolves the run's own address (the
|
|
512
|
+
* source `persistMail` depends on to record a triggered deployment's mail).
|
|
513
|
+
*/
|
|
514
|
+
export async function resolveRoutableAddress(db, address) {
|
|
515
|
+
const runRow = await db
|
|
516
|
+
.select({
|
|
517
|
+
id: workflowRun.id,
|
|
518
|
+
tenantId: workflowRun.tenantId,
|
|
519
|
+
publicKey: workflowRun.publicKey,
|
|
520
|
+
status: workflowRun.status,
|
|
521
|
+
principalId: workflowRun.principalId,
|
|
522
|
+
})
|
|
523
|
+
.from(workflowRun)
|
|
524
|
+
.where(and(eq(workflowRun.address, address), isNull(workflowRun.endedAt)))
|
|
525
|
+
.limit(1)
|
|
526
|
+
.then((rows) => rows[0]);
|
|
527
|
+
if (runRow === undefined) {
|
|
528
|
+
return undefined;
|
|
529
|
+
}
|
|
530
|
+
return {
|
|
531
|
+
id: runRow.id,
|
|
532
|
+
tenantId: runRow.tenantId,
|
|
533
|
+
address,
|
|
534
|
+
publicKey: runRow.publicKey,
|
|
535
|
+
status: runRow.status,
|
|
536
|
+
sessionId: await resolveRunSessionId(db, runRow.principalId),
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* A folded run has no session column; its session is the `agent_session` keyed
|
|
541
|
+
* by the run's principal. By default this is the live (not-yet-ended) session,
|
|
542
|
+
* matching routing semantics; `includeEnded` also resolves a stopped run's
|
|
543
|
+
* ended session, which mail history needs. Returns null when the run has no
|
|
544
|
+
* principal or no matching session. Transitional, alongside
|
|
545
|
+
* `RoutableEndpoint.sessionId`.
|
|
546
|
+
*/
|
|
547
|
+
export async function resolveRunSessionId(db, principalId, opts = {}) {
|
|
548
|
+
if (principalId === null) {
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
// One session per run principal (invariant), so limit(1) returns the whole
|
|
552
|
+
// history; order deterministically so a hypothetical second row cannot make
|
|
553
|
+
// the pick flap. If a run ever grows multiple sessions per principal this
|
|
554
|
+
// becomes a union and limit(1) silently truncates.
|
|
555
|
+
const conditions = [eq(agentSession.principalId, principalId)];
|
|
556
|
+
if (opts.includeEnded !== true) {
|
|
557
|
+
conditions.push(isNull(agentSession.endedAt));
|
|
190
558
|
}
|
|
191
|
-
|
|
559
|
+
const row = await db
|
|
560
|
+
.select({ id: agentSession.id })
|
|
561
|
+
.from(agentSession)
|
|
562
|
+
.where(and(...conditions))
|
|
563
|
+
.orderBy(asc(agentSession.createdAt))
|
|
564
|
+
.limit(1)
|
|
565
|
+
.then((rows) => rows[0]);
|
|
566
|
+
return row?.id ?? null;
|
|
192
567
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
568
|
+
/**
|
|
569
|
+
* The folded run that owns a session, or null when the session belongs to no
|
|
570
|
+
* run. This is the inverse of `resolveRunSessionId`: a mail-read path holds a
|
|
571
|
+
* `sessionMail.sessionId` and no address, so it recovers the owning run by
|
|
572
|
+
* joining `workflow_run` to `agent_session` on their shared principal (a folded
|
|
573
|
+
* run, its session, and its launch all key on the same `instancePrincipalId`).
|
|
574
|
+
* Scoped to the tenant and routed through `workflow_run` so the returned id is
|
|
575
|
+
* proven to name a real run of this tenant -- callers key an authorization
|
|
576
|
+
* subject on it, so a session held by a non-run principal must fail closed to
|
|
577
|
+
* null rather than resolve to a fabricated subject.
|
|
578
|
+
*/
|
|
579
|
+
export async function resolveRunIdForSession(db, sessionId, tenantId) {
|
|
580
|
+
// No `endedAt` filter: a stopped run's mail must stay fetchable, so the
|
|
581
|
+
// session resolves whether or not it has ended. The run principal is minted
|
|
582
|
+
// per launch and shared 1:1 by the run and its session, so at most one row
|
|
583
|
+
// matches; order deterministically anyway so a hypothetical second row cannot
|
|
584
|
+
// make the pick flap, mirroring `resolveRunSessionId`.
|
|
585
|
+
const row = await db
|
|
586
|
+
.select({ id: workflowRun.id })
|
|
587
|
+
.from(workflowRun)
|
|
588
|
+
.innerJoin(agentSession, eq(agentSession.principalId, workflowRun.principalId))
|
|
589
|
+
.where(and(eq(agentSession.id, sessionId), eq(workflowRun.tenantId, tenantId)))
|
|
590
|
+
.orderBy(asc(workflowRun.createdAt))
|
|
591
|
+
.limit(1)
|
|
592
|
+
.then((rows) => rows[0]);
|
|
593
|
+
return row?.id ?? null;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Shape a run row and its already-resolved routing address into the run
|
|
597
|
+
* record. Callers decide whether the run resolves at all -- only a top-level
|
|
598
|
+
* run (`isTopLevelRun`) does -- and pass the address they have narrowed; this
|
|
599
|
+
* only maps the columns, including the run's `endedAt ?? createdAt` stand-in
|
|
600
|
+
* for the absent `updatedAt`.
|
|
601
|
+
*/
|
|
602
|
+
export function runRowToRoutableRecord(run, address) {
|
|
603
|
+
return {
|
|
604
|
+
id: run.id,
|
|
605
|
+
tenantId: run.tenantId,
|
|
606
|
+
address,
|
|
607
|
+
publicKey: run.publicKey,
|
|
608
|
+
status: run.status,
|
|
609
|
+
createdAt: run.createdAt,
|
|
610
|
+
updatedAt: run.endedAt ?? run.createdAt,
|
|
611
|
+
endedAt: run.endedAt,
|
|
612
|
+
definitionId: run.definitionId,
|
|
613
|
+
principalId: run.principalId,
|
|
614
|
+
kernelId: run.kernelId,
|
|
615
|
+
sidecarId: run.sidecarId,
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* A run is a top-level run -- the addressable head of a deployment -- when it
|
|
620
|
+
* owns a routing address AND self-anchors (`anchorRunId === id`). A lazy child
|
|
621
|
+
* park row anchors on its parent (`anchorRunId !== id`) and carries no address;
|
|
622
|
+
* either condition excludes it. This is the single predicate the run read
|
|
623
|
+
* surface classifies on, so the resolver and the run list cannot drift.
|
|
624
|
+
*/
|
|
625
|
+
export function isTopLevelRun(row) {
|
|
626
|
+
return row.address !== null && row.anchorRunId === row.id;
|
|
197
627
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
628
|
+
/**
|
|
629
|
+
* Resolve a run id to its record. A run resolves only when it is a top-level
|
|
630
|
+
* run (`isTopLevelRun`): it owns a routing address and self-anchors. A child
|
|
631
|
+
* park row (address-null, anchored on its parent) is not served here.
|
|
632
|
+
*/
|
|
633
|
+
export async function findRoutableById(db, id, tenantId) {
|
|
634
|
+
const runRow = await db
|
|
635
|
+
.select({
|
|
636
|
+
id: workflowRun.id,
|
|
637
|
+
tenantId: workflowRun.tenantId,
|
|
638
|
+
address: workflowRun.address,
|
|
639
|
+
anchorRunId: workflowRun.anchorRunId,
|
|
640
|
+
publicKey: workflowRun.publicKey,
|
|
641
|
+
status: workflowRun.status,
|
|
642
|
+
createdAt: workflowRun.createdAt,
|
|
643
|
+
endedAt: workflowRun.endedAt,
|
|
644
|
+
principalId: workflowRun.principalId,
|
|
645
|
+
kernelId: workflowRun.kernelId,
|
|
646
|
+
sidecarId: workflowRun.sidecarId,
|
|
647
|
+
definitionId: workflowRun.definitionId,
|
|
648
|
+
})
|
|
649
|
+
.from(workflowRun)
|
|
650
|
+
.where(and(eq(workflowRun.id, id), eq(workflowRun.tenantId, tenantId)))
|
|
651
|
+
.limit(1)
|
|
652
|
+
.then((rows) => rows[0]);
|
|
653
|
+
// The `address === null` arm is redundant with `isTopLevelRun` (which already
|
|
654
|
+
// requires a non-null address) but narrows `address` from `string | null` to
|
|
655
|
+
// `string` for `runRowToRoutableRecord`, which `isTopLevelRun`'s boolean
|
|
656
|
+
// return cannot do.
|
|
657
|
+
if (runRow === undefined ||
|
|
658
|
+
!isTopLevelRun(runRow) ||
|
|
659
|
+
runRow.address === null) {
|
|
660
|
+
return undefined;
|
|
202
661
|
}
|
|
203
|
-
return
|
|
662
|
+
return runRowToRoutableRecord(runRow, runRow.address);
|
|
204
663
|
}
|