@ifsoul/core 0.4.0 → 0.5.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.
@@ -0,0 +1,647 @@
1
+ import { behaviorPermissions, behaviorRevision, behaviorEvidenceVisible, effectiveBehavior, } from "./behavior.js";
2
+ import { resolveResourceLimits, BUFFERED_JSON_MAX_BYTES, } from "../../protocol/src/resource-policy.js";
3
+ import { SessionError } from "./participants.js";
4
+ export const DEFAULT_PLANNING_LIMITS = Object.freeze({
5
+ principalCalls: 64,
6
+ npcCalls: 32,
7
+ groupCalls: 32,
8
+ concurrentPlans: 1,
9
+ schedules: 32,
10
+ claims: 256,
11
+ reports: 1024,
12
+ runsPerSchedule: 32,
13
+ actorsPerPlan: 8,
14
+ assignmentsPerPlan: 16,
15
+ assignmentsPerActor: 4,
16
+ sharedEvents: 32,
17
+ contextBytes: 32768,
18
+ });
19
+ export function resolvePlanningLimits(requested = {}, ceilings = {}) {
20
+ return resolveResourceLimits({
21
+ layer: "group-scheduler",
22
+ defaults: { ...DEFAULT_PLANNING_LIMITS },
23
+ legacyCeilings: { ...DEFAULT_PLANNING_LIMITS },
24
+ requested,
25
+ ceilings,
26
+ implementationCeilings: {
27
+ actorsPerPlan: 64,
28
+ assignmentsPerPlan: 1024,
29
+ contextBytes: BUFFERED_JSON_MAX_BYTES,
30
+ },
31
+ });
32
+ }
33
+ const fail = (code) => {
34
+ throw new SessionError(code);
35
+ };
36
+ const id = (x) => typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(x);
37
+ const ids = (x, maximum = 32) => Array.isArray(x) &&
38
+ x.length <= maximum &&
39
+ x.every(id) &&
40
+ new Set(x).size === x.length;
41
+ const integer = (x) => Number.isSafeInteger(x) && Number(x) >= 0;
42
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
43
+ const keys = (x, allowed) => Object.keys(x).every((k) => allowed.includes(k));
44
+ const initial = () => ({
45
+ format: "ifsoul-group-scheduler/0.4.0",
46
+ schedules: [],
47
+ claims: [],
48
+ reports: [],
49
+ cursors: [],
50
+ lastWallTime: 0,
51
+ calls: 0,
52
+ npcCalls: {},
53
+ groupCalls: {},
54
+ });
55
+ const count = (values, key) => Object.hasOwn(values, key) ? values[key] : 0;
56
+ const open = (c) => ["active", "proposed", "unknown"].includes(c.status);
57
+ function authorizedSession(s, access, sessionId, revision) {
58
+ if (!access || !id(access.principalId) || !id(access.hostId))
59
+ return fail("unauthorized");
60
+ const session = s.sessions.find((x) => x.sessionId === sessionId && x.hostId === access.hostId);
61
+ if (!session)
62
+ return fail("unauthorized");
63
+ if (revision !== undefined && session.revision !== revision)
64
+ return fail("stale");
65
+ return session;
66
+ }
67
+ function validateSpec(s, access, spec, limits, operator) {
68
+ if (!spec ||
69
+ !keys(spec, [
70
+ "sessionId",
71
+ "sessionRevision",
72
+ "groupId",
73
+ "actors",
74
+ "intents",
75
+ "objective",
76
+ "channelId",
77
+ "sharedEventIds",
78
+ "goalId",
79
+ ]) ||
80
+ !id(spec.sessionId) ||
81
+ !integer(spec.sessionRevision) ||
82
+ typeof spec.objective !== "string" ||
83
+ spec.objective.length < 1 ||
84
+ spec.objective.length > 512 ||
85
+ !id(spec.channelId) ||
86
+ (spec.goalId !== undefined && !id(spec.goalId)) ||
87
+ !ids(spec.sharedEventIds, limits.sharedEvents) ||
88
+ !Array.isArray(spec.actors) ||
89
+ spec.actors.length < 1 ||
90
+ spec.actors.length > limits.actorsPerPlan ||
91
+ !ids(spec.actors.map((x) => x?.participantId), limits.actorsPerPlan) ||
92
+ !Array.isArray(spec.intents) ||
93
+ spec.intents.length < 1 ||
94
+ spec.intents.length > 16 ||
95
+ !ids(spec.intents.map((x) => x?.intentId)))
96
+ return fail("invalid");
97
+ const session = authorizedSession(s, access, spec.sessionId, spec.sessionRevision);
98
+ const actorIds = spec.actors.map((x) => x?.participantId);
99
+ if (spec.goalId !== undefined &&
100
+ actorIds.some((npcId) => !effectiveBehavior(s, access, npcId, spec.sessionId, operator).goals.some((g) => g.goalId === spec.goalId &&
101
+ g.enabled &&
102
+ g.objective === spec.objective)))
103
+ return fail("unauthorized");
104
+ if (spec.actors.some((x) => !keys(x, ["participantId", "roleId", "capabilities"]) ||
105
+ !id(x.roleId) ||
106
+ !ids(x.capabilities) ||
107
+ !session.members.some((m) => m.participantId === x.participantId && m.role === "npc")))
108
+ return fail("unauthorized");
109
+ if (spec.intents.some((x) => !keys(x, ["intentId", "roleIds", "requiredCapabilities"]) ||
110
+ !ids(x.roleIds) ||
111
+ !x.roleIds.length ||
112
+ !ids(x.requiredCapabilities)))
113
+ return fail("invalid");
114
+ if (spec.groupId !== undefined) {
115
+ const group = session.groups.find((x) => x.groupId === spec.groupId);
116
+ if (!group || actorIds.some((x) => !group.participantIds.includes(x)))
117
+ return fail("unauthorized");
118
+ }
119
+ else if (actorIds.length !== 1)
120
+ return fail("invalid");
121
+ const channel = session.channels.find((x) => x.channelId === spec.channelId);
122
+ if (!channel || actorIds.some((x) => !channel.participantIds.includes(x)))
123
+ return fail("unauthorized");
124
+ const sharedEvents = spec.sharedEventIds.map((eventId) => {
125
+ const event = s.events.find((x) => x.eventId === eventId &&
126
+ x.sessionId === session.sessionId &&
127
+ x.channelId === spec.channelId);
128
+ if (!event ||
129
+ (s.behavior &&
130
+ actorIds.some((npcId) => !behaviorEvidenceVisible(s, access, npcId, spec.sessionId, eventId))) ||
131
+ actorIds.some((x) => ![
132
+ event.senderId,
133
+ ...event.recipientIds,
134
+ ...event.observerIds,
135
+ ].includes(x)))
136
+ return fail("unauthorized");
137
+ return {
138
+ eventId: event.eventId,
139
+ sessionId: event.sessionId,
140
+ revision: event.revision,
141
+ channelId: event.channelId,
142
+ senderId: event.senderId,
143
+ recipientIds: [...event.recipientIds],
144
+ observerIds: [...event.observerIds],
145
+ text: event.text,
146
+ };
147
+ });
148
+ if (new TextEncoder().encode(JSON.stringify({ spec, sharedEvents })).length >
149
+ limits.contextBytes)
150
+ return fail("limit");
151
+ return {
152
+ session,
153
+ sharedEvents,
154
+ actorIds,
155
+ groupKey: JSON.stringify([
156
+ session.sessionId,
157
+ spec.groupId === undefined
158
+ ? ["solo", actorIds[0]]
159
+ : ["group", spec.groupId],
160
+ ]),
161
+ };
162
+ }
163
+ /** Reject the whole proposal. One invalid assignment never grants another action. */
164
+ export function validGroupPlan(value, spec, limits = { ...DEFAULT_PLANNING_LIMITS }) {
165
+ if (!value || typeof value !== "object" || Array.isArray(value))
166
+ return false;
167
+ const plan = value;
168
+ if (!keys(plan, ["objective", "assignments"]) ||
169
+ plan.objective !== spec.objective ||
170
+ !Array.isArray(plan.assignments) ||
171
+ !plan.assignments.length ||
172
+ plan.assignments.length > limits.assignmentsPerPlan ||
173
+ !ids(plan.assignments.map((x) => x?.assignmentId), limits.assignmentsPerPlan))
174
+ return false;
175
+ if (spec.actors.some((actor) => !plan.assignments.some((a) => a.actorId === actor.participantId)))
176
+ return false;
177
+ for (const a of plan.assignments) {
178
+ if (!a ||
179
+ !keys(a, ["assignmentId", "actorId", "intentId", "dependsOn"]) ||
180
+ !ids(a.dependsOn, limits.assignmentsPerPlan) ||
181
+ a.dependsOn.includes(a.assignmentId) ||
182
+ a.dependsOn.some((d) => !plan.assignments.some((x) => x.assignmentId === d)))
183
+ return false;
184
+ const actor = spec.actors.find((x) => x.participantId === a.actorId);
185
+ const intent = spec.intents.find((x) => x.intentId === a.intentId);
186
+ if (!actor ||
187
+ !intent ||
188
+ !intent.roleIds.includes(actor.roleId) ||
189
+ intent.requiredCapabilities.some((c) => !actor.capabilities.includes(c)) ||
190
+ plan.assignments.filter((x) => x.actorId === a.actorId).length >
191
+ limits.assignmentsPerActor)
192
+ return false;
193
+ }
194
+ const visiting = new Set(), done = new Set();
195
+ const visit = (assignmentId) => {
196
+ if (visiting.has(assignmentId))
197
+ return false;
198
+ if (done.has(assignmentId))
199
+ return true;
200
+ visiting.add(assignmentId);
201
+ if (!plan.assignments
202
+ .find((x) => x.assignmentId === assignmentId)
203
+ .dependsOn.every(visit))
204
+ return false;
205
+ visiting.delete(assignmentId);
206
+ done.add(assignmentId);
207
+ return true;
208
+ };
209
+ return plan.assignments.every((x) => visit(x.assignmentId));
210
+ }
211
+ export function createGroupSchedulerRuntime(store, requestedBudgets = {}, ceilings = {}, behaviorOperator) {
212
+ behaviorOperator = behaviorOperator
213
+ ? structuredClone(behaviorOperator)
214
+ : undefined;
215
+ const report = resolvePlanningLimits(requestedBudgets, ceilings);
216
+ if (report.status !== "accepted")
217
+ throw new RangeError(`Unsupported planning budget: ${report.failure?.field}`);
218
+ const budgets = report.effective;
219
+ const run = (access, work) => {
220
+ if (!access || !id(access.principalId) || !id(access.hostId))
221
+ return fail("unauthorized");
222
+ return store.transact(access.principalId, (s) => {
223
+ if (!id(access.principalId) ||
224
+ !id(access.hostId) ||
225
+ s.format !== "ifsoul-participants/0.4.0")
226
+ return fail("unauthorized");
227
+ if (!s.planning || s.planning.format !== "ifsoul-group-scheduler/0.4.0")
228
+ return fail("invalid");
229
+ return structuredClone(work(s, s.planning));
230
+ });
231
+ };
232
+ const schedule = (s, p, access, scheduleId) => {
233
+ const config = p.schedules.find((x) => x.scheduleId === scheduleId);
234
+ if (!config)
235
+ return fail("unauthorized");
236
+ authorizedSession(s, access, config.spec.sessionId);
237
+ return config;
238
+ };
239
+ const release = (s, c) => {
240
+ for (const t of s.turns)
241
+ if (t.ticket.turnId === `${c.claimId}:${t.ticket.npcId}` &&
242
+ c.actorIds.includes(t.ticket.npcId) &&
243
+ t.ticket.eventId === c.claimId)
244
+ t.cancelled = true;
245
+ };
246
+ const supersede = (s, p, scheduleId) => {
247
+ for (const c of p.claims.filter((x) => x.scheduleId === scheduleId && open(x))) {
248
+ c.status =
249
+ c.status === "unknown" || c.adoptedIds?.length
250
+ ? "unknown"
251
+ : "superseded";
252
+ release(s, c);
253
+ }
254
+ };
255
+ const clockValue = (config, session, clock) => {
256
+ const value = config.clock === "wall"
257
+ ? clock.wallTimeMs
258
+ : clock.worldTicks[session.worldId];
259
+ if (!integer(clock.wallTimeMs) || !integer(value))
260
+ return fail("invalid");
261
+ return value;
262
+ };
263
+ return {
264
+ configure(access, expectedRevision, config) {
265
+ if (!access || !id(access.principalId) || !id(access.hostId))
266
+ return fail("unauthorized");
267
+ // New scheduling state is created only by this explicit trusted configuration operation.
268
+ return store.transact(access.principalId, (s) => {
269
+ if (!id(access.principalId) ||
270
+ !id(access.hostId) ||
271
+ s.format !== "ifsoul-participants/0.4.0")
272
+ return fail("unauthorized");
273
+ if (!config ||
274
+ !keys(config, [
275
+ "scheduleId",
276
+ "revision",
277
+ "state",
278
+ "spec",
279
+ "clock",
280
+ "interval",
281
+ "nextDue",
282
+ "maxRuns",
283
+ ]) ||
284
+ !id(config.scheduleId) ||
285
+ !integer(expectedRevision) ||
286
+ config.revision !== expectedRevision + 1 ||
287
+ !["running", "paused", "cancelled"].includes(config.state) ||
288
+ !["wall", "world"].includes(config.clock) ||
289
+ !integer(config.interval) ||
290
+ config.interval < 1 ||
291
+ config.interval > 86400000 ||
292
+ !integer(config.nextDue) ||
293
+ !integer(config.maxRuns) ||
294
+ config.maxRuns < 1 ||
295
+ config.maxRuns > budgets.runsPerSchedule)
296
+ return fail("invalid");
297
+ validateSpec(s, access, config.spec, budgets, behaviorOperator);
298
+ const p = s.planning ?? initial();
299
+ if (p.format !== "ifsoul-group-scheduler/0.4.0")
300
+ return fail("invalid");
301
+ const old = p.schedules.find((x) => x.scheduleId === config.scheduleId);
302
+ if (old) {
303
+ authorizedSession(s, access, old.spec.sessionId);
304
+ if (old.spec.sessionId !== config.spec.sessionId ||
305
+ old.clock !== config.clock ||
306
+ old.state === "cancelled")
307
+ return fail("conflict");
308
+ }
309
+ if ((old?.revision ?? 0) !== expectedRevision)
310
+ return fail("stale");
311
+ if (!old && p.schedules.length >= budgets.schedules)
312
+ return fail("limit");
313
+ supersede(s, p, config.scheduleId);
314
+ if (old)
315
+ p.schedules[p.schedules.indexOf(old)] = structuredClone(config);
316
+ else {
317
+ p.schedules.push(structuredClone(config));
318
+ p.cursors.push({
319
+ scheduleId: config.scheduleId,
320
+ runs: 0,
321
+ triggerKeys: [],
322
+ });
323
+ }
324
+ s.planning = p;
325
+ return structuredClone(config);
326
+ });
327
+ },
328
+ control(access, scheduleId, expectedRevision, action) {
329
+ return run(access, (s, p) => {
330
+ const config = schedule(s, p, access, scheduleId);
331
+ if (config.revision !== expectedRevision)
332
+ return fail("stale");
333
+ if (config.state === "cancelled" ||
334
+ !["pause", "resume", "cancel"].includes(action))
335
+ return fail("conflict");
336
+ supersede(s, p, scheduleId);
337
+ config.revision++;
338
+ config.state =
339
+ action === "pause"
340
+ ? "paused"
341
+ : action === "resume"
342
+ ? "running"
343
+ : "cancelled";
344
+ return config;
345
+ });
346
+ },
347
+ list(access) {
348
+ if (!access || !id(access.principalId) || !id(access.hostId))
349
+ return fail("unauthorized");
350
+ return store.transact(access.principalId, (s) => {
351
+ if (!id(access.principalId) || !id(access.hostId))
352
+ return fail("unauthorized");
353
+ if (s.format !== "ifsoul-participants/0.4.0")
354
+ return fail("invalid");
355
+ if (!s.planning)
356
+ return [];
357
+ if (s.planning.format !== "ifsoul-group-scheduler/0.4.0")
358
+ return fail("invalid");
359
+ return structuredClone(s.planning.schedules.filter((x) => s.sessions.some((session) => session.sessionId === x.spec.sessionId &&
360
+ session.hostId === access.hostId)));
361
+ });
362
+ },
363
+ claim(access, scheduleId, trigger, clock, workerId, leaseMs, hostAvailable) {
364
+ return run(access, (s, p) => {
365
+ const config = schedule(s, p, access, scheduleId);
366
+ const { session, sharedEvents, actorIds, groupKey } = validateSpec(s, access, config.spec, budgets, behaviorOperator);
367
+ if (!id(workerId) ||
368
+ !integer(leaseMs) ||
369
+ leaseMs < 1 ||
370
+ leaseMs > 60000 ||
371
+ !trigger ||
372
+ !["due", "manual", "event"].includes(trigger.kind))
373
+ return fail("invalid");
374
+ const now = clockValue(config, session, clock);
375
+ if (clock.wallTimeMs < p.lastWallTime)
376
+ return fail("stale");
377
+ p.lastWallTime = clock.wallTimeMs;
378
+ for (const expired of p.claims.filter((c) => c.status === "active" && c.leaseUntil <= clock.wallTimeMs)) {
379
+ expired.status = "unknown";
380
+ release(s, expired);
381
+ }
382
+ if (config.state !== "running")
383
+ return { status: "paused" };
384
+ if (!hostAvailable)
385
+ return { status: "host-unavailable" };
386
+ if (actorIds.some((npcId) => {
387
+ const p = behaviorPermissions(s, access, npcId, config.spec.sessionId, trigger.kind === "event" ? trigger.eventId : undefined, behaviorOperator);
388
+ return !p.observe || !p.requestAction;
389
+ }))
390
+ return { status: "policy-denied" };
391
+ const cursor = p.cursors.find((x) => x.scheduleId === scheduleId);
392
+ if (cursor.lastClock !== undefined && now < cursor.lastClock)
393
+ return fail("stale");
394
+ cursor.lastClock = now;
395
+ let triggerKey;
396
+ if (trigger.kind === "due") {
397
+ if (now < config.nextDue)
398
+ return { status: "not-due" };
399
+ triggerKey = `due:${config.nextDue}`;
400
+ }
401
+ else if (trigger.kind === "manual") {
402
+ if (!id(trigger.triggerId))
403
+ return fail("invalid");
404
+ triggerKey = `manual:${trigger.triggerId}`;
405
+ }
406
+ else {
407
+ if (!id(trigger.eventId) ||
408
+ !config.spec.sharedEventIds.includes(trigger.eventId))
409
+ return fail("unauthorized");
410
+ triggerKey = `event:${trigger.eventId}`;
411
+ }
412
+ if (cursor.triggerKeys.includes(triggerKey))
413
+ return { status: "replay" };
414
+ if (p.claims.some((c) => c.status === "unknown" &&
415
+ (c.groupKey === groupKey ||
416
+ c.actorIds.some((id) => actorIds.includes(id)))))
417
+ return { status: "unknown" };
418
+ // Trusted principal concurrency is bounded independently of per-group and per-NPC ownership.
419
+ if (p.claims.filter(open).length >= budgets.concurrentPlans ||
420
+ p.claims.some((c) => open(c) &&
421
+ (c.groupKey === groupKey ||
422
+ c.actorIds.some((id) => actorIds.includes(id)))) ||
423
+ s.turns.some((t) => actorIds.includes(t.ticket.npcId) &&
424
+ !t.output &&
425
+ !t.cancelled &&
426
+ s.sessions.some((x) => x.sessionId === t.ticket.sessionId &&
427
+ x.revision === t.ticket.revision)))
428
+ return { status: "busy" };
429
+ if (cursor.lastStarted !== undefined &&
430
+ now < cursor.lastStarted + config.interval)
431
+ return { status: "not-due" };
432
+ if (p.calls >= budgets.principalCalls ||
433
+ cursor.runs >= config.maxRuns ||
434
+ actorIds.some((x) => count(p.npcCalls, x) >= budgets.npcCalls) ||
435
+ count(p.groupCalls, groupKey) >= budgets.groupCalls ||
436
+ p.claims.length >= budgets.claims ||
437
+ s.turns.length + actorIds.length > 2048)
438
+ return { status: "budget" };
439
+ if (!integer(now + config.interval) ||
440
+ !integer(clock.wallTimeMs + leaseMs))
441
+ return fail("invalid");
442
+ const fence = p.claims.length + 1, claimId = `plan-${fence}`;
443
+ if (actorIds.some((x) => s.turns.some((t) => t.ticket.turnId === `${claimId}:${x}`)))
444
+ return fail("conflict");
445
+ const claim = {
446
+ claimId,
447
+ fence,
448
+ behaviorRevision: behaviorRevision(s),
449
+ workerId,
450
+ scheduleId,
451
+ scheduleRevision: config.revision,
452
+ sessionRevision: session.revision,
453
+ leaseUntil: clock.wallTimeMs + leaseMs,
454
+ triggerKey,
455
+ actorIds,
456
+ groupKey,
457
+ status: "active",
458
+ };
459
+ p.claims.push(claim);
460
+ p.calls++;
461
+ cursor.runs++;
462
+ cursor.lastStarted = now;
463
+ cursor.triggerKeys.push(triggerKey);
464
+ // Coalesce missed slots into one current planning request, never offline actions.
465
+ config.nextDue = now + config.interval;
466
+ p.groupCalls[groupKey] = count(p.groupCalls, groupKey) + 1;
467
+ for (const npcId of actorIds) {
468
+ p.npcCalls[npcId] = count(p.npcCalls, npcId) + 1;
469
+ s.turns.push({
470
+ ticket: {
471
+ turnId: `${claimId}:${npcId}`,
472
+ eventId: claimId,
473
+ sessionId: session.sessionId,
474
+ revision: session.revision,
475
+ npcId,
476
+ recipientIds: [],
477
+ observerIds: [],
478
+ },
479
+ });
480
+ }
481
+ const views = actorIds.map((npcId) => effectiveBehavior(s, access, npcId, config.spec.sessionId, behaviorOperator));
482
+ const policies = actorIds.map((npcId) => behaviorPermissions(s, access, npcId, config.spec.sessionId, trigger.kind === "event" ? trigger.eventId : undefined, behaviorOperator));
483
+ const sharedPolicy = Object.fromEntries(Object.keys(policies[0]).map((flag) => [
484
+ flag,
485
+ policies.every((p) => p[flag]),
486
+ ]));
487
+ const commonGoals = views[0].goals.filter((goal) => goal.enabled &&
488
+ views.every((view) => view.goals.some((other) => other.goalId === goal.goalId &&
489
+ other.objective === goal.objective &&
490
+ other.enabled)));
491
+ return {
492
+ claim,
493
+ spec: config.spec,
494
+ sharedEvents,
495
+ ...(s.behavior
496
+ ? {
497
+ behavior: {
498
+ configRevision: behaviorRevision(s),
499
+ policy: sharedPolicy,
500
+ goals: commonGoals,
501
+ },
502
+ }
503
+ : {}),
504
+ };
505
+ });
506
+ },
507
+ complete(access, ticket, output, clock) {
508
+ return run(access, (s, p) => {
509
+ const c = p.claims.find((x) => x.claimId === ticket.claimId);
510
+ if (!c ||
511
+ c.fence !== ticket.fence ||
512
+ c.workerId !== ticket.workerId ||
513
+ c.status !== "active")
514
+ return fail("stale");
515
+ const config = schedule(s, p, access, c.scheduleId);
516
+ if ((c.behaviorRevision ?? 0) !== behaviorRevision(s))
517
+ return fail("stale");
518
+ authorizedSession(s, access, config.spec.sessionId, c.sessionRevision);
519
+ if (config.revision !== c.scheduleRevision ||
520
+ config.state !== "running" ||
521
+ !integer(clock.wallTimeMs) ||
522
+ clock.wallTimeMs < p.lastWallTime ||
523
+ clock.wallTimeMs >= c.leaseUntil)
524
+ return fail("stale");
525
+ validateSpec(s, access, config.spec, budgets, behaviorOperator);
526
+ if (!validGroupPlan(output, config.spec, budgets))
527
+ return fail("invalid");
528
+ c.plan = structuredClone(output);
529
+ c.status = "proposed";
530
+ release(s, c);
531
+ return c;
532
+ });
533
+ },
534
+ fail(access, ticket, uncertain) {
535
+ return run(access, (s, p) => {
536
+ const c = p.claims.find((x) => x.claimId === ticket.claimId);
537
+ if (!c ||
538
+ c.fence !== ticket.fence ||
539
+ c.workerId !== ticket.workerId ||
540
+ c.status !== "active")
541
+ return fail("stale");
542
+ schedule(s, p, access, c.scheduleId);
543
+ c.status = uncertain ? "unknown" : "failed";
544
+ release(s, c);
545
+ return c;
546
+ });
547
+ },
548
+ report(access, report) {
549
+ return run(access, (s, p) => {
550
+ if (!report ||
551
+ !keys(report, [
552
+ "reportId",
553
+ "claimId",
554
+ "status",
555
+ "assignmentIds",
556
+ "summary",
557
+ ]) ||
558
+ !id(report.reportId) ||
559
+ !ids(report.assignmentIds, budgets.assignmentsPerPlan) ||
560
+ typeof report.summary !== "string" ||
561
+ report.summary.length < 1 ||
562
+ report.summary.length > 1024 ||
563
+ !["adopted", "rejected", "completed", "failed", "unknown"].includes(report.status))
564
+ return fail("invalid");
565
+ const c = p.claims.find((x) => x.claimId === report.claimId);
566
+ if (!c)
567
+ return fail("unauthorized");
568
+ const config = schedule(s, p, access, c.scheduleId);
569
+ const prior = p.reports.find((x) => x.reportId === report.reportId);
570
+ if (prior) {
571
+ if (!equal(prior, report))
572
+ return fail("conflict");
573
+ return c;
574
+ }
575
+ if (!c.plan ||
576
+ report.assignmentIds.some((x) => !c.plan.assignments.some((a) => a.assignmentId === x)) ||
577
+ p.reports.length >= budgets.reports)
578
+ return fail("invalid");
579
+ if (report.status === "adopted") {
580
+ if ((c.behaviorRevision ?? 0) !== behaviorRevision(s))
581
+ return fail("stale");
582
+ authorizedSession(s, access, config.spec.sessionId, c.sessionRevision);
583
+ if (config.revision !== c.scheduleRevision ||
584
+ config.state !== "running" ||
585
+ c.status !== "proposed" ||
586
+ c.adoptedIds ||
587
+ !report.assignmentIds.length)
588
+ return fail("stale");
589
+ if (report.assignmentIds.some((assignmentId) => c
590
+ .plan.assignments.find((a) => a.assignmentId === assignmentId)
591
+ .dependsOn.some((dependency) => !report.assignmentIds.includes(dependency))))
592
+ return fail("invalid");
593
+ c.adoptedIds = [...report.assignmentIds];
594
+ }
595
+ else if (report.status === "rejected") {
596
+ if (report.assignmentIds.length !== 0 ||
597
+ c.adoptedIds?.length ||
598
+ !["proposed", "unknown", "superseded"].includes(c.status))
599
+ return fail("conflict");
600
+ c.status = "resolved";
601
+ }
602
+ else if (report.status === "completed" ||
603
+ report.status === "failed") {
604
+ if (!c.adoptedIds?.length ||
605
+ !equal([...report.assignmentIds].sort(), [...c.adoptedIds].sort()) ||
606
+ c.status === "resolved")
607
+ return fail("conflict");
608
+ c.status = "resolved";
609
+ }
610
+ else {
611
+ if (!c.adoptedIds?.length || c.status === "resolved")
612
+ return fail("conflict");
613
+ c.status = "unknown";
614
+ }
615
+ p.reports.push(structuredClone(report));
616
+ return c;
617
+ });
618
+ },
619
+ reconcile(access, claimId) {
620
+ return run(access, (s, p) => {
621
+ const c = p.claims.find((x) => x.claimId === claimId);
622
+ if (!c)
623
+ return fail("unauthorized");
624
+ schedule(s, p, access, c.scheduleId);
625
+ // Only an uncertain model request with no host adoption may be acknowledged here.
626
+ // Adopted work requires correlated host outcome reports instead.
627
+ if (c.status !== "unknown" || c.adoptedIds?.length)
628
+ return fail("conflict");
629
+ c.status = "resolved";
630
+ release(s, c);
631
+ return c;
632
+ });
633
+ },
634
+ inspect(access, scheduleId) {
635
+ return run(access, (s, p) => {
636
+ const config = schedule(s, p, access, scheduleId);
637
+ return {
638
+ config,
639
+ cursor: p.cursors.find((x) => x.scheduleId === scheduleId),
640
+ claims: p.claims.filter((x) => x.scheduleId === scheduleId),
641
+ reports: p.reports.filter((r) => p.claims.some((c) => c.claimId === r.claimId && c.scheduleId === scheduleId)),
642
+ calls: p.calls,
643
+ };
644
+ });
645
+ },
646
+ };
647
+ }
@@ -7,3 +7,7 @@ export declare function resolveRuntimeLimits(requested?: Partial<RuntimeLimits>,
7
7
  export declare const exchangeKey: (characterId: string, category: string, id: string) => string;
8
8
  /** A bounded local orchestrator. No keys, provider construction, host execution or SQLite dependency. */
9
9
  export declare function createRuntime(options: RuntimeOptions): Runtime;
10
+ export * from "./participants.js";
11
+ export * from "./group-scheduler.js";
12
+ export type * from "./behavior-types.js";
13
+ export * from "./behavior.js";
@@ -505,3 +505,6 @@ export function createRuntime(options) {
505
505
  }),
506
506
  };
507
507
  }
508
+ export * from "./participants.js";
509
+ export * from "./group-scheduler.js";
510
+ export * from "./behavior.js";