@godspeedai/cognate-runtime-bun 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +39 -2
- package/package.json +38 -4
- package/src/cognition.ts +66 -0
- package/src/core.ts +36 -0
- package/src/executor.ts +423 -0
- package/src/fold.ts +96 -0
- package/src/governance-env.ts +35 -0
- package/src/governed-operation.ts +248 -0
- package/src/index.ts +95 -0
- package/src/inspect.ts +58 -0
- package/src/invoker.ts +145 -0
- package/src/remote-offers.ts +60 -0
- package/src/runtime.ts +235 -0
- package/src/sea-forge-authority.ts +401 -0
- package/src/service.ts +517 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// In-process RuntimeService (ADR-0011): authorization, tenancy, and typed errors over the durable core.
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import type { ContinuationRecord, TransitionOutcome } from "@godspeedai/cognate-continuations";
|
|
4
|
+
import { ConcurrencyError, fingerprint, IdempotencyMismatchError, type Json, type StoredEvent } from "@godspeedai/cognate-events";
|
|
5
|
+
import { applyStateUpdate, type Caller, type ContinuationView, foldState, type ObservationInput, type ObservationView, type RunView, type RuntimeService, ServiceError, type ServiceErrorCode, StatePatchError } from "@godspeedai/cognate-runtime-api";
|
|
6
|
+
import type { Core } from "./core.ts";
|
|
7
|
+
import type { ActionName } from "./governed-operation.ts";
|
|
8
|
+
import { foldRun, isTerminal, observationStream, RUN_EXECUTE_TOPIC, RUNS, runStateStream, runStream, threadStateStream, toJson } from "./fold.ts";
|
|
9
|
+
|
|
10
|
+
const PAGE = 500;
|
|
11
|
+
|
|
12
|
+
export function createService(core: Core, publicProjections: ReadonlySet<string>): RuntimeService {
|
|
13
|
+
const { store } = core;
|
|
14
|
+
|
|
15
|
+
const authorize = async (caller: Caller, action: ActionName, resource: string): Promise<void> => {
|
|
16
|
+
const decision = await core.governor.govern({ kind: "action", action, caller, resource });
|
|
17
|
+
if (!decision.allow) throw new ServiceError("permission_denied", decision.reason);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
async function readRun(caller: Caller, runId: string): Promise<{ view: RunView; events: StoredEvent[] }> {
|
|
21
|
+
const events = await store.readStream(runStream(runId));
|
|
22
|
+
const view = foldRun(runId, events);
|
|
23
|
+
if (!view || view.tenant !== caller.tenant) throw new ServiceError("not_found", `run ${runId} not found`);
|
|
24
|
+
return { view, events };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function continuationFor(caller: Caller, id: string): Promise<ContinuationRecord> {
|
|
28
|
+
const record = await core.continuations.get(id);
|
|
29
|
+
if (!record || record.tenant !== caller.tenant) throw new ServiceError("not_found", `continuation ${id} not found`);
|
|
30
|
+
return record;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
async startRun(caller, request) {
|
|
35
|
+
await authorize(caller, "run.start", request.agent);
|
|
36
|
+
const agent = core.agents.get(request.agent);
|
|
37
|
+
if (!agent) throw new ServiceError("not_found", `agent ${request.agent} not found`);
|
|
38
|
+
const runId = randomUUID();
|
|
39
|
+
const correlationId = request.correlationId ?? randomUUID();
|
|
40
|
+
const occurredAt = new Date().toISOString();
|
|
41
|
+
try {
|
|
42
|
+
const outcome = await store.commit({
|
|
43
|
+
idempotency: {
|
|
44
|
+
scope: `${caller.tenant}:${caller.actor.id}:run.start`,
|
|
45
|
+
key: request.idempotencyKey,
|
|
46
|
+
// `state` is part of the fingerprint: reusing a key with a different seed is a different request.
|
|
47
|
+
fingerprint: fingerprint({ agent: request.agent, input: request.input, correlationId: request.correlationId ?? null, state: request.state ?? null }),
|
|
48
|
+
},
|
|
49
|
+
appends: [
|
|
50
|
+
{
|
|
51
|
+
streamId: runStream(runId),
|
|
52
|
+
expectedVersion: 0,
|
|
53
|
+
events: [
|
|
54
|
+
{
|
|
55
|
+
eventType: "run.started",
|
|
56
|
+
schemaVersion: 1,
|
|
57
|
+
occurredAt,
|
|
58
|
+
tenant: caller.tenant,
|
|
59
|
+
correlationId,
|
|
60
|
+
causationId: null,
|
|
61
|
+
runId,
|
|
62
|
+
scopeId: core.scopeId,
|
|
63
|
+
payload: { input: request.input },
|
|
64
|
+
metadata: { agent: agent.id, agentVersion: agent.version, startedBy: caller.actor.id, startedByKind: caller.actor.kind },
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
// Caller-seeded state, committed atomically with `run.started` on the state stream so the
|
|
69
|
+
// agent's first `ctx.state()` read (even before suspending) always sees it (spec §29.1).
|
|
70
|
+
...(request.state !== undefined
|
|
71
|
+
? [
|
|
72
|
+
{
|
|
73
|
+
streamId: runStateStream(runId),
|
|
74
|
+
expectedVersion: 0,
|
|
75
|
+
events: [
|
|
76
|
+
{
|
|
77
|
+
eventType: "state.changed",
|
|
78
|
+
schemaVersion: 1,
|
|
79
|
+
occurredAt,
|
|
80
|
+
tenant: caller.tenant,
|
|
81
|
+
correlationId,
|
|
82
|
+
causationId: null,
|
|
83
|
+
runId,
|
|
84
|
+
scopeId: core.scopeId,
|
|
85
|
+
payload: { snapshot: request.state },
|
|
86
|
+
metadata: { source: "caller", actor: caller.actor.id },
|
|
87
|
+
},
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
]
|
|
91
|
+
: []),
|
|
92
|
+
],
|
|
93
|
+
records: [{ collection: RUNS, id: runId, expectedVersion: 0, value: { tenant: caller.tenant, agent: agent.id, correlationId, startedBy: caller.actor.id, scopeId: core.scopeId } }],
|
|
94
|
+
outbox: [{ id: `${RUN_EXECUTE_TOPIC}:${runId}`, topic: RUN_EXECUTE_TOPIC, payload: { runId } }],
|
|
95
|
+
result: { runId },
|
|
96
|
+
});
|
|
97
|
+
core.kick();
|
|
98
|
+
return (await readRun(caller, (outcome.result as { runId: string }).runId)).view;
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (error instanceof IdempotencyMismatchError) throw new ServiceError("already_exists", `idempotency key ${request.idempotencyKey} was used for a different request`);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
async getRun(caller, request) {
|
|
106
|
+
return (await readRun(caller, request.runId)).view;
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async cancelRun(caller, request) {
|
|
110
|
+
await authorize(caller, "run.cancel", request.runId);
|
|
111
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
112
|
+
const { view, events } = await readRun(caller, request.runId);
|
|
113
|
+
if (isTerminal(view)) return view;
|
|
114
|
+
try {
|
|
115
|
+
await store.commit({
|
|
116
|
+
idempotency: { scope: `${caller.tenant}:${caller.actor.id}:run.cancel`, key: request.idempotencyKey, fingerprint: fingerprint({ runId: request.runId, reason: request.reason }) },
|
|
117
|
+
appends: [
|
|
118
|
+
{
|
|
119
|
+
streamId: runStream(request.runId),
|
|
120
|
+
expectedVersion: events.length,
|
|
121
|
+
events: [
|
|
122
|
+
{
|
|
123
|
+
eventType: "run.cancelled",
|
|
124
|
+
schemaVersion: 1,
|
|
125
|
+
occurredAt: new Date().toISOString(),
|
|
126
|
+
tenant: view.tenant,
|
|
127
|
+
correlationId: view.correlationId,
|
|
128
|
+
causationId: events.at(-1)!.eventId,
|
|
129
|
+
runId: request.runId,
|
|
130
|
+
scopeId: events[0]!.scopeId,
|
|
131
|
+
payload: { reason: request.reason },
|
|
132
|
+
metadata: { cancelledBy: caller.actor.id },
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
result: { runId: request.runId },
|
|
138
|
+
});
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error instanceof ConcurrencyError) continue;
|
|
141
|
+
if (error instanceof IdempotencyMismatchError) throw new ServiceError("already_exists", `idempotency key ${request.idempotencyKey} was used for a different request`);
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
core.controllers.get(request.runId)?.abort(new Error("run cancelled"));
|
|
147
|
+
for (const c of await core.continuations.list({ runId: request.runId, status: "OPEN" })) {
|
|
148
|
+
await core.continuations.cancel(c.id, { actor: caller.actor, idempotencyKey: `run.cancel:${request.idempotencyKey}:${c.id}`, reason: request.reason });
|
|
149
|
+
}
|
|
150
|
+
return (await readRun(caller, request.runId)).view;
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
events(caller, request) {
|
|
154
|
+
return eventStream(core, caller, request);
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
async listContinuations(caller, request) {
|
|
158
|
+
const records = await core.continuations.list({ tenant: caller.tenant, ...(request.runId ? { runId: request.runId } : {}), ...(request.status ? { status: request.status } : {}) });
|
|
159
|
+
return records.map(continuationView);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async resolveContinuation(caller, request) {
|
|
163
|
+
const record = await continuationFor(caller, request.continuationId);
|
|
164
|
+
await authorize(caller, request.action === "resume" ? "continuation.resume" : "continuation.cancel", request.continuationId);
|
|
165
|
+
const action = { actor: caller.actor, idempotencyKey: request.idempotencyKey, ...(request.expectedVersion !== undefined ? { expectedVersion: request.expectedVersion } : {}) };
|
|
166
|
+
const outcome: TransitionOutcome =
|
|
167
|
+
request.action === "resume"
|
|
168
|
+
? await core.continuations.resume(record.id, { ...action, input: request.input ?? null })
|
|
169
|
+
: await core.continuations.cancel(record.id, { ...action, reason: request.reason ?? "cancelled" });
|
|
170
|
+
switch (outcome.kind) {
|
|
171
|
+
case "transitioned":
|
|
172
|
+
case "duplicate":
|
|
173
|
+
case "already_terminal":
|
|
174
|
+
// A cancelled wait fails the run with ContinuationClosedError on re-entry; a resume is delivered via the outbox.
|
|
175
|
+
if (outcome.kind === "transitioned" && request.action === "cancel") void core.execute(record.runId).catch(() => {});
|
|
176
|
+
core.kick();
|
|
177
|
+
return { outcome: outcome.kind, continuation: continuationView(outcome.record) };
|
|
178
|
+
case "denied":
|
|
179
|
+
throw new ServiceError("permission_denied", outcome.reason);
|
|
180
|
+
case "version_mismatch":
|
|
181
|
+
throw new ServiceError("failed_precondition", `continuation ${record.id} is at version ${outcome.record.version}`);
|
|
182
|
+
case "not_found":
|
|
183
|
+
throw new ServiceError("not_found", `continuation ${record.id} not found`);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async invoke(caller, request) {
|
|
188
|
+
const outcome = await core.invoker.invoke({
|
|
189
|
+
capability: request.capability,
|
|
190
|
+
input: request.input,
|
|
191
|
+
tenant: caller.tenant,
|
|
192
|
+
context: {
|
|
193
|
+
actor: caller.actor,
|
|
194
|
+
correlationId: request.correlationId ?? randomUUID(),
|
|
195
|
+
...(request.idempotencyKey ? { idempotencyKey: request.idempotencyKey } : {}),
|
|
196
|
+
...(request.deadline !== undefined ? { deadline: request.deadline } : {}),
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
if (outcome.ok) return outcome.value;
|
|
200
|
+
const kind = outcome.failure!.kind;
|
|
201
|
+
const codes: Record<string, ServiceErrorCode> = { denied: "permission_denied", unavailable: "unavailable", deadline_exceeded: "deadline_exceeded", cancelled: "unavailable", failed: "failed_precondition" };
|
|
202
|
+
throw new ServiceError(codes[kind] ?? "failed_precondition", `invocation of ${request.capability} failed: ${JSON.stringify(outcome.failure)}`);
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async getArtifact(caller, request) {
|
|
206
|
+
const result = await core.artifacts.get(request.artifactId, caller.actor, caller.tenant, request.representation);
|
|
207
|
+
switch (result.kind) {
|
|
208
|
+
case "ok":
|
|
209
|
+
return { id: result.record.id, name: result.record.name, mediaType: result.record.mediaType, contentHash: result.record.contentHash, bytes: result.bytes };
|
|
210
|
+
case "lost":
|
|
211
|
+
throw new ServiceError("unavailable", `artifact ${request.artifactId} bytes are missing`);
|
|
212
|
+
default:
|
|
213
|
+
// Denied and tombstoned artifacts are indistinguishable from absent ones to the caller (spec §32).
|
|
214
|
+
throw new ServiceError("not_found", `artifact ${request.artifactId} not found`);
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
async readProjection(caller, request) {
|
|
219
|
+
if (!publicProjections.has(request.name)) throw new ServiceError("not_found", `projection ${request.name} not found`);
|
|
220
|
+
// Public projections are tenant-partitioned by key prefix `<tenant>/` (spec §32); an empty
|
|
221
|
+
// partition is [] so the caller cannot tell whether other tenants have data.
|
|
222
|
+
const prefix = `${caller.tenant}/`;
|
|
223
|
+
return (await store.listProjection(request.name)).filter((e) => e.key.startsWith(prefix)).map((e) => ({ key: e.key.slice(prefix.length), value: e.value }));
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
async listRuns(caller, request) {
|
|
227
|
+
const where: { [field: string]: string } = { tenant: caller.tenant };
|
|
228
|
+
if (request.correlationId !== undefined) where.correlationId = request.correlationId;
|
|
229
|
+
if (request.agent !== undefined) where.agent = request.agent;
|
|
230
|
+
const records = await store.listRecords(RUNS, where);
|
|
231
|
+
// Ordered by each run's `run.started` store position (the store-wide commit order, spec §10) — exact
|
|
232
|
+
// and contention-free, unlike a counter bumped on every startRun. One indexed stream read per matched
|
|
233
|
+
// run (not an event-log scan): `readStream(..., { limit: 1 })` reads only the first event.
|
|
234
|
+
const withPosition = await Promise.all(
|
|
235
|
+
records.map(async (record) => {
|
|
236
|
+
const [started] = await store.readStream(runStream(record.id), { limit: 1 });
|
|
237
|
+
return { runId: record.id, position: started?.position ?? 0 };
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
withPosition.sort((a, b) => a.position - b.position);
|
|
241
|
+
const views: RunView[] = [];
|
|
242
|
+
for (const { runId } of withPosition) views.push((await readRun(caller, runId)).view);
|
|
243
|
+
return views;
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
async updateState(caller, request) {
|
|
247
|
+
await authorize(caller, "run.state.update", request.runId);
|
|
248
|
+
const stateStreamId = runStateStream(request.runId);
|
|
249
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
250
|
+
const { view, events } = await readRun(caller, request.runId);
|
|
251
|
+
if (isTerminal(view)) throw new ServiceError("failed_precondition", `run ${request.runId} is terminal`);
|
|
252
|
+
const stateEvents = await store.readStream(stateStreamId);
|
|
253
|
+
const currentVersion = stateEvents.length;
|
|
254
|
+
if (request.expectedVersion !== undefined && request.expectedVersion !== currentVersion) {
|
|
255
|
+
throw new ServiceError("failed_precondition", `run ${request.runId} state is at version ${currentVersion}`);
|
|
256
|
+
}
|
|
257
|
+
const currentState = foldState([...events, ...stateEvents]);
|
|
258
|
+
let nextState: Json;
|
|
259
|
+
try {
|
|
260
|
+
nextState = applyStateUpdate(currentState, request.update);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (error instanceof StatePatchError) throw new ServiceError("invalid_argument", error.message);
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
const outcome = await store.commit({
|
|
267
|
+
idempotency: {
|
|
268
|
+
scope: `${caller.tenant}:${caller.actor.id}:run.state.update`,
|
|
269
|
+
key: request.idempotencyKey,
|
|
270
|
+
fingerprint: fingerprint({ runId: request.runId, update: toJson(request.update), expectedVersion: request.expectedVersion ?? null }),
|
|
271
|
+
},
|
|
272
|
+
appends: [
|
|
273
|
+
{
|
|
274
|
+
streamId: stateStreamId,
|
|
275
|
+
expectedVersion: currentVersion,
|
|
276
|
+
events: [
|
|
277
|
+
{
|
|
278
|
+
eventType: "state.changed",
|
|
279
|
+
schemaVersion: 1,
|
|
280
|
+
occurredAt: new Date().toISOString(),
|
|
281
|
+
tenant: view.tenant,
|
|
282
|
+
correlationId: view.correlationId,
|
|
283
|
+
causationId: stateEvents.at(-1)?.eventId ?? events.at(-1)!.eventId,
|
|
284
|
+
runId: request.runId,
|
|
285
|
+
scopeId: events[0]!.scopeId,
|
|
286
|
+
payload: toJson(request.update),
|
|
287
|
+
metadata: { source: "caller", actor: caller.actor.id },
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
],
|
|
292
|
+
result: { state: nextState, version: currentVersion + 1 },
|
|
293
|
+
});
|
|
294
|
+
return outcome.result as { state: Json; version: number };
|
|
295
|
+
} catch (error) {
|
|
296
|
+
if (error instanceof ConcurrencyError) continue;
|
|
297
|
+
if (error instanceof IdempotencyMismatchError) throw new ServiceError("already_exists", `idempotency key ${request.idempotencyKey} was used for a different request`);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
throw new ServiceError("unavailable", `too much contention updating state for run ${request.runId}`);
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async readSharedState(caller, request) {
|
|
305
|
+
await authorize(caller, "thread.state.read", request.threadId);
|
|
306
|
+
const events = await store.readStream(threadStateStream(caller.tenant, request.threadId));
|
|
307
|
+
return { state: foldState(events), version: events.length };
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
async updateSharedState(caller, request) {
|
|
311
|
+
await authorize(caller, "thread.state.update", request.threadId);
|
|
312
|
+
const streamId = threadStateStream(caller.tenant, request.threadId);
|
|
313
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
314
|
+
const events = await store.readStream(streamId);
|
|
315
|
+
const currentVersion = events.length;
|
|
316
|
+
let state: Json;
|
|
317
|
+
try {
|
|
318
|
+
state = applyStateUpdate(foldState(events), request.update);
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error instanceof StatePatchError) throw new ServiceError("invalid_argument", error.message);
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
try {
|
|
324
|
+
const outcome = await store.commit({
|
|
325
|
+
idempotency: {
|
|
326
|
+
scope: `${caller.tenant}:${caller.actor.id}:thread.state.update`,
|
|
327
|
+
key: request.idempotencyKey,
|
|
328
|
+
fingerprint: fingerprint({ threadId: request.threadId, update: toJson(request.update), expectedVersion: request.expectedVersion ?? null }),
|
|
329
|
+
},
|
|
330
|
+
appends: [{
|
|
331
|
+
streamId,
|
|
332
|
+
// Let the store check idempotency before the CAS. A duplicate request must return its
|
|
333
|
+
// recorded result even if a later write has advanced the stream since the first call.
|
|
334
|
+
expectedVersion: request.expectedVersion ?? currentVersion,
|
|
335
|
+
events: [{
|
|
336
|
+
eventType: "state.changed",
|
|
337
|
+
schemaVersion: 1,
|
|
338
|
+
occurredAt: new Date().toISOString(),
|
|
339
|
+
tenant: caller.tenant,
|
|
340
|
+
correlationId: request.threadId,
|
|
341
|
+
causationId: events.at(-1)?.eventId ?? null,
|
|
342
|
+
runId: null,
|
|
343
|
+
scopeId: core.scopeId,
|
|
344
|
+
payload: toJson(request.update),
|
|
345
|
+
metadata: { scope: "thread", threadId: request.threadId, source: "caller", actor: caller.actor.id },
|
|
346
|
+
}],
|
|
347
|
+
}],
|
|
348
|
+
result: { state, version: currentVersion + 1 },
|
|
349
|
+
});
|
|
350
|
+
return outcome.result as { state: Json; version: number };
|
|
351
|
+
} catch (error) {
|
|
352
|
+
if (error instanceof ConcurrencyError) {
|
|
353
|
+
if (request.expectedVersion !== undefined && request.expectedVersion !== currentVersion) {
|
|
354
|
+
throw new ServiceError("failed_precondition", `thread ${request.threadId} state is at version ${currentVersion}`);
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
if (error instanceof IdempotencyMismatchError) throw new ServiceError("already_exists", `idempotency key ${request.idempotencyKey} was used for a different request`);
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
throw new ServiceError("unavailable", `too much contention updating state for thread ${request.threadId}`);
|
|
363
|
+
},
|
|
364
|
+
|
|
365
|
+
async observe(caller: Caller, request: ObservationInput): Promise<ObservationView> {
|
|
366
|
+
const worldId = request.subject.worldId;
|
|
367
|
+
const resource = request.subject.resource;
|
|
368
|
+
await authorize(caller, "observation.record", `${worldId ?? "-"}:${resource}`);
|
|
369
|
+
const streamId = observationStream(caller.tenant, worldId, resource);
|
|
370
|
+
// Observation identity is stable for the idempotency scope+key: a replay returns the same id and the
|
|
371
|
+
// recorded result (no second event). No clock/randomness enters the id, so replay is deterministic.
|
|
372
|
+
const idemScope = `${caller.tenant}:${caller.actor.id}:observation.record`;
|
|
373
|
+
const observationId = `obs_${fingerprint({ scope: idemScope, key: request.idempotencyKey })}`;
|
|
374
|
+
const attribution = {
|
|
375
|
+
kind: request.attribution?.kind ?? ("unattributed" as const),
|
|
376
|
+
confidence: request.attribution?.confidence ?? ("observed" as const),
|
|
377
|
+
};
|
|
378
|
+
const correlationId = request.attribution?.correlationId ?? observationId;
|
|
379
|
+
// Causation is cited ONLY when attribution kind is "caused"; otherwise it stays null (unknown, never invented).
|
|
380
|
+
const causationId = request.attribution?.kind === "caused" ? (request.attribution.causationEventId ?? null) : null;
|
|
381
|
+
const recordedAt = new Date().toISOString();
|
|
382
|
+
const view: ObservationView = {
|
|
383
|
+
observationId,
|
|
384
|
+
eventId: observationId,
|
|
385
|
+
kind: request.kind,
|
|
386
|
+
subject: request.subject,
|
|
387
|
+
observedAt: request.observedAt,
|
|
388
|
+
recordedAt,
|
|
389
|
+
attribution: { ...attribution, correlationId, causationEventId: causationId },
|
|
390
|
+
};
|
|
391
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
392
|
+
const events = await store.readStream(streamId);
|
|
393
|
+
const currentVersion = events.length;
|
|
394
|
+
try {
|
|
395
|
+
const outcome = await store.commit({
|
|
396
|
+
idempotency: {
|
|
397
|
+
scope: idemScope,
|
|
398
|
+
key: request.idempotencyKey,
|
|
399
|
+
fingerprint: fingerprint(toJson({ kind: request.kind, subject: request.subject, facts: request.facts, attribution: request.attribution ?? null, observedAt: request.observedAt })),
|
|
400
|
+
},
|
|
401
|
+
appends: [{
|
|
402
|
+
streamId,
|
|
403
|
+
expectedVersion: currentVersion,
|
|
404
|
+
events: [{
|
|
405
|
+
eventType: "observation.recorded",
|
|
406
|
+
schemaVersion: 1,
|
|
407
|
+
occurredAt: request.observedAt,
|
|
408
|
+
tenant: caller.tenant,
|
|
409
|
+
correlationId,
|
|
410
|
+
causationId,
|
|
411
|
+
runId: null,
|
|
412
|
+
scopeId: core.scopeId,
|
|
413
|
+
eventId: observationId,
|
|
414
|
+
payload: toJson({ kind: request.kind, subject: request.subject, facts: request.facts, evidence: request.evidence ?? null, attribution }),
|
|
415
|
+
metadata: {
|
|
416
|
+
source: "observation",
|
|
417
|
+
observer: request.source.observer,
|
|
418
|
+
provider: request.source.provider ?? null,
|
|
419
|
+
method: request.source.method ?? null,
|
|
420
|
+
worldId: worldId ?? null,
|
|
421
|
+
recordedBy: caller.actor.id,
|
|
422
|
+
attribution: attribution.kind,
|
|
423
|
+
confidence: attribution.confidence,
|
|
424
|
+
},
|
|
425
|
+
}],
|
|
426
|
+
}],
|
|
427
|
+
result: toJson(view),
|
|
428
|
+
});
|
|
429
|
+
return outcome.result as unknown as ObservationView;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (error instanceof ConcurrencyError) {
|
|
432
|
+
if (attempt >= 4) break;
|
|
433
|
+
continue; // contention on this subject's stream: re-read and retry
|
|
434
|
+
}
|
|
435
|
+
if (error instanceof IdempotencyMismatchError) throw new ServiceError("already_exists", `idempotency key ${request.idempotencyKey} was used for a different request`);
|
|
436
|
+
throw error;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
throw new ServiceError("unavailable", `too much contention recording observations for ${worldId ?? "-"}:${resource}`);
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
async publishRemoteOffer(caller, request) {
|
|
443
|
+
await authorize(caller, "remote.offer.publish", `${request.agent}:${request.contract.id}`);
|
|
444
|
+
if (!core.agents.has(request.agent)) throw new ServiceError("not_found", `agent ${request.agent} not found`);
|
|
445
|
+
try {
|
|
446
|
+
return core.remoteOffers.publish(caller, core.scopeId, request);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
throw new ServiceError("invalid_argument", error instanceof Error ? error.message : "invalid remote offer");
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
|
|
452
|
+
async listRemoteOffers(caller, request) {
|
|
453
|
+
await authorize(caller, "remote.offer.list", request.agent);
|
|
454
|
+
if (!core.agents.has(request.agent)) throw new ServiceError("not_found", `agent ${request.agent} not found`);
|
|
455
|
+
const offers = core.remoteOffers.list(caller, request.agent);
|
|
456
|
+
const allowed = await Promise.all(offers.map(async (offer) => (await core.governor.govern({ kind: "action", action: "remote.offer.invoke", caller, resource: offer.id })).allow));
|
|
457
|
+
return offers.filter((_, index) => allowed[index]);
|
|
458
|
+
},
|
|
459
|
+
|
|
460
|
+
async revokeRemoteOffer(caller, request) {
|
|
461
|
+
await authorize(caller, "remote.offer.revoke", request.offerId);
|
|
462
|
+
// Hide cross-tenant or foreign-owner offers exactly like other service reads.
|
|
463
|
+
if (!core.remoteOffers.revoke(caller, request.offerId)) throw new ServiceError("not_found", `remote offer ${request.offerId} not found`);
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function continuationView(record: ContinuationRecord): ContinuationView {
|
|
469
|
+
return { id: record.id, runId: record.runId, type: record.type, status: record.status, condition: record.condition, payload: record.payload, version: record.version, expiresAt: record.expiresAt };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Catch up from `afterPosition`, then (when following) switch to live commits without gaps or duplicates. */
|
|
473
|
+
async function* eventStream(core: Core, caller: Caller, request: { readonly afterPosition?: number; readonly runId?: string; readonly correlationId?: string; readonly signal?: AbortSignal; readonly follow?: boolean }): AsyncGenerator<StoredEvent> {
|
|
474
|
+
const visible = (e: StoredEvent) =>
|
|
475
|
+
e.tenant === caller.tenant &&
|
|
476
|
+
(request.runId === undefined || e.runId === request.runId) &&
|
|
477
|
+
(request.correlationId === undefined || e.correlationId === request.correlationId);
|
|
478
|
+
const signal = request.signal;
|
|
479
|
+
let position = request.afterPosition ?? 0;
|
|
480
|
+
|
|
481
|
+
// Subscribe before catching up so nothing committed in between is missed; dedupe by position.
|
|
482
|
+
const buffer: StoredEvent[] = [];
|
|
483
|
+
let wake: (() => void) | undefined;
|
|
484
|
+
const notify = () => {
|
|
485
|
+
wake?.();
|
|
486
|
+
wake = undefined;
|
|
487
|
+
};
|
|
488
|
+
const unsubscribe = request.follow ? core.store.onCommitted((events) => (buffer.push(...events), notify())) : () => {};
|
|
489
|
+
signal?.addEventListener("abort", notify, { once: true });
|
|
490
|
+
try {
|
|
491
|
+
for (;;) {
|
|
492
|
+
if (signal?.aborted) return;
|
|
493
|
+
const page = await core.store.readAll({ afterPosition: position, limit: PAGE });
|
|
494
|
+
for (const event of page) {
|
|
495
|
+
position = event.position;
|
|
496
|
+
if (visible(event)) yield event;
|
|
497
|
+
if (signal?.aborted) return;
|
|
498
|
+
}
|
|
499
|
+
if (page.length < PAGE) break;
|
|
500
|
+
}
|
|
501
|
+
if (!request.follow) return;
|
|
502
|
+
for (;;) {
|
|
503
|
+
if (signal?.aborted) return;
|
|
504
|
+
if (buffer.length === 0) {
|
|
505
|
+
await new Promise<void>((resolve) => (wake = resolve));
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const next = buffer.shift()!;
|
|
509
|
+
if (next.position <= position) continue;
|
|
510
|
+
position = next.position;
|
|
511
|
+
if (visible(next)) yield next;
|
|
512
|
+
}
|
|
513
|
+
} finally {
|
|
514
|
+
unsubscribe();
|
|
515
|
+
signal?.removeEventListener("abort", notify);
|
|
516
|
+
}
|
|
517
|
+
}
|