agentcorp-broker 0.1.0-alpha.1

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.
Files changed (71) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/CONTRIBUTING.md +20 -0
  3. package/LICENSE +201 -0
  4. package/README.md +250 -0
  5. package/SECURITY.md +23 -0
  6. package/dist/audit.d.ts +25 -0
  7. package/dist/audit.js +203 -0
  8. package/dist/audit.js.map +1 -0
  9. package/dist/broker.d.ts +103 -0
  10. package/dist/broker.js +805 -0
  11. package/dist/broker.js.map +1 -0
  12. package/dist/cli.d.ts +2 -0
  13. package/dist/cli.js +712 -0
  14. package/dist/cli.js.map +1 -0
  15. package/dist/config.d.ts +3 -0
  16. package/dist/config.js +41 -0
  17. package/dist/config.js.map +1 -0
  18. package/dist/console/console.css +794 -0
  19. package/dist/console/console.js +802 -0
  20. package/dist/console/index.html +309 -0
  21. package/dist/credentials.d.ts +16 -0
  22. package/dist/credentials.js +72 -0
  23. package/dist/credentials.js.map +1 -0
  24. package/dist/database.d.ts +119 -0
  25. package/dist/database.js +1356 -0
  26. package/dist/database.js.map +1 -0
  27. package/dist/diagnostics.d.ts +44 -0
  28. package/dist/diagnostics.js +357 -0
  29. package/dist/diagnostics.js.map +1 -0
  30. package/dist/errors.d.ts +5 -0
  31. package/dist/errors.js +14 -0
  32. package/dist/errors.js.map +1 -0
  33. package/dist/index.d.ts +23 -0
  34. package/dist/index.js +15 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/mcp.d.ts +3 -0
  37. package/dist/mcp.js +215 -0
  38. package/dist/mcp.js.map +1 -0
  39. package/dist/migrations.d.ts +13 -0
  40. package/dist/migrations.js +266 -0
  41. package/dist/migrations.js.map +1 -0
  42. package/dist/policy.d.ts +22 -0
  43. package/dist/policy.js +33 -0
  44. package/dist/policy.js.map +1 -0
  45. package/dist/server.d.ts +49 -0
  46. package/dist/server.js +529 -0
  47. package/dist/server.js.map +1 -0
  48. package/dist/stdio-adapter.d.ts +230 -0
  49. package/dist/stdio-adapter.js +406 -0
  50. package/dist/stdio-adapter.js.map +1 -0
  51. package/dist/tui.d.ts +26 -0
  52. package/dist/tui.js +291 -0
  53. package/dist/tui.js.map +1 -0
  54. package/dist/types.d.ts +355 -0
  55. package/dist/types.js +85 -0
  56. package/dist/types.js.map +1 -0
  57. package/docs/ARCHITECTURE.md +119 -0
  58. package/docs/README.md +37 -0
  59. package/docs/RELEASING.md +228 -0
  60. package/docs/ROADMAP.md +112 -0
  61. package/docs/cli-reference.md +133 -0
  62. package/docs/dogfooding-report.md +83 -0
  63. package/docs/getting-started.md +228 -0
  64. package/docs/guides/antigravity-setup.md +84 -0
  65. package/docs/guides/claude-cursor-setup.md +76 -0
  66. package/docs/guides/codex-setup.md +75 -0
  67. package/docs/guides/human-console.md +177 -0
  68. package/docs/mcp-tools-reference.md +235 -0
  69. package/docs/policy-guide.md +105 -0
  70. package/examples/org.toml +65 -0
  71. package/package.json +68 -0
package/dist/broker.js ADDED
@@ -0,0 +1,805 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { EventEmitter } from "node:events";
3
+ import { encodeCursor, policyFromConfig } from "./database.js";
4
+ import { AgentCorpError, invariant } from "./errors.js";
5
+ import { evaluatePolicy } from "./policy.js";
6
+ import { MessageTypeSchema, InitialPolicySchema, TaskStatusSchema, } from "./types.js";
7
+ const TASK_TRANSITIONS = {
8
+ proposed: ["assigned", "cancelled"],
9
+ assigned: ["in_progress", "cancelled"],
10
+ in_progress: ["blocked", "awaiting_review", "failed", "cancelled"],
11
+ blocked: ["in_progress", "failed", "cancelled"],
12
+ awaiting_review: ["in_progress", "completed", "failed", "cancelled"],
13
+ completed: [],
14
+ failed: [],
15
+ cancelled: [],
16
+ };
17
+ export const DEFAULT_MAX_PAYLOAD_SIZE_BYTES = 1 * 1024 * 1024; // 1 MB
18
+ export const DEFAULT_MAX_ARTIFACT_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB
19
+ export class AgentCorpBroker extends EventEmitter {
20
+ config;
21
+ database;
22
+ maxPayloadSizeBytes;
23
+ maxArtifactSizeBytes;
24
+ roles;
25
+ now;
26
+ id;
27
+ constructor(config, database, options = {}) {
28
+ super();
29
+ this.config = config;
30
+ this.database = database;
31
+ this.maxPayloadSizeBytes = options.limits?.maxPayloadSizeBytes ?? config.limits?.max_message_payload_bytes ?? config.limits?.max_payload_size_bytes ?? DEFAULT_MAX_PAYLOAD_SIZE_BYTES;
32
+ this.maxArtifactSizeBytes = options.limits?.maxArtifactSizeBytes ?? config.limits?.max_artifact_bytes ?? config.limits?.max_artifact_size_bytes ?? DEFAULT_MAX_ARTIFACT_SIZE_BYTES;
33
+ this.roles = new Map(config.roles.map((role) => [role.id, role]));
34
+ this.now = options.now ?? (() => new Date());
35
+ this.id = options.id ?? ((prefix) => `${prefix}_${randomUUID()}`);
36
+ this.seedPolicies();
37
+ }
38
+ emitDomainEvent(type, data) {
39
+ const event = {
40
+ type,
41
+ data,
42
+ timestamp: this.timestamp(),
43
+ };
44
+ this.emit("event", event);
45
+ this.emit(type, event);
46
+ }
47
+ timestamp() {
48
+ return this.now().toISOString();
49
+ }
50
+ role(roleId) {
51
+ const role = this.roles.get(roleId);
52
+ invariant(role, "UNKNOWN_ROLE", `Unknown role: ${roleId}`);
53
+ return role;
54
+ }
55
+ touchPresence(callerRole) {
56
+ this.role(callerRole);
57
+ try {
58
+ this.database.touchRolePresence(callerRole, this.timestamp());
59
+ }
60
+ catch {
61
+ // Non-critical if table or column not yet migrated
62
+ }
63
+ }
64
+ executeIdempotent(callerRole, operation, idempotencyKey, requestPayload, fn) {
65
+ this.touchPresence(callerRole);
66
+ if (!idempotencyKey) {
67
+ return fn();
68
+ }
69
+ const currentHash = createHash("sha256")
70
+ .update(JSON.stringify(requestPayload ?? null))
71
+ .digest("hex");
72
+ return this.database.transaction(() => {
73
+ const existing = this.database.getIdempotency(idempotencyKey, callerRole);
74
+ if (existing) {
75
+ if (existing.operation !== operation) {
76
+ throw new AgentCorpError("IDEMPOTENCY_CONFLICT", `Idempotency key '${idempotencyKey}' was already used for operation '${existing.operation}', cannot reuse for '${operation}'`);
77
+ }
78
+ if (existing.requestHash && existing.requestHash !== currentHash) {
79
+ throw new AgentCorpError("IDEMPOTENCY_CONFLICT", `Idempotency key '${idempotencyKey}' was already used with a different request payload`);
80
+ }
81
+ return JSON.parse(existing.responseJson);
82
+ }
83
+ const result = fn();
84
+ this.database.saveIdempotency({
85
+ key: idempotencyKey,
86
+ roleId: callerRole,
87
+ operation,
88
+ requestHash: currentHash,
89
+ responseJson: JSON.stringify(result),
90
+ createdAt: this.timestamp(),
91
+ });
92
+ return result;
93
+ });
94
+ }
95
+ getOperation(callerRole, key) {
96
+ this.touchPresence(callerRole);
97
+ return this.database.getIdempotency(key, callerRole);
98
+ }
99
+ seedPolicies() {
100
+ const timestamp = this.timestamp();
101
+ this.database.transaction(() => {
102
+ for (const initial of this.config.policies) {
103
+ const id = initial.id ?? this.id("pol");
104
+ if (this.database.getPolicy(id))
105
+ continue;
106
+ const rule = policyFromConfig(initial, id, timestamp);
107
+ if (rule.action === "delegate_to_role") {
108
+ throw new AgentCorpError("UNSUPPORTED_POLICY", `Policy ${rule.id} uses delegate_to_role, which is reserved for a future release`);
109
+ }
110
+ this.database.insertPolicy(rule);
111
+ }
112
+ });
113
+ }
114
+ getRole(roleId) {
115
+ return this.role(roleId);
116
+ }
117
+ registerRole(roleId, agentId, declaredCapabilities) {
118
+ this.touchPresence(roleId);
119
+ const role = this.role(roleId);
120
+ const undeclared = declaredCapabilities.filter((capability) => !role.capabilities.includes(capability));
121
+ invariant(undeclared.length === 0, "CAPABILITY_ESCALATION", `Role ${roleId} cannot claim undeclared capabilities: ${undeclared.join(", ")}`);
122
+ this.database.bindRole(roleId, agentId, declaredCapabilities, this.timestamp());
123
+ this.emitDomainEvent("role_registered", { roleId, agentId, capabilities: declaredCapabilities });
124
+ return role;
125
+ }
126
+ createTask(callerRole, input) {
127
+ return this.executeIdempotent(callerRole, "createTask", input.idempotencyKey, input, () => {
128
+ this.role(callerRole);
129
+ if (input.assignedTo) {
130
+ this.assertRoute(callerRole, input.assignedTo);
131
+ }
132
+ const timestamp = this.timestamp();
133
+ const task = {
134
+ taskId: this.id("task"),
135
+ title: input.title,
136
+ description: input.description ?? null,
137
+ createdBy: callerRole,
138
+ assignedTo: input.assignedTo ?? null,
139
+ // An intended assignee must not receive actionable work until a linked
140
+ // proposal has passed policy/human approval.
141
+ status: "proposed",
142
+ createdAt: timestamp,
143
+ updatedAt: timestamp,
144
+ };
145
+ this.database.insertTask(task);
146
+ this.emitDomainEvent("task_created", task);
147
+ return task;
148
+ });
149
+ }
150
+ listTasksPaginated(callerRole, options) {
151
+ this.touchPresence(callerRole);
152
+ return this.database.listTasksForRolePaginated(callerRole, options);
153
+ }
154
+ listTasks(callerRole, options) {
155
+ this.touchPresence(callerRole);
156
+ return this.database.listTasksForRole(callerRole, options);
157
+ }
158
+ sendMessage(callerRole, input) {
159
+ return this.executeIdempotent(callerRole, "sendMessage", input.idempotencyKey, input, () => {
160
+ this.assertRoute(callerRole, input.toRole);
161
+ MessageTypeSchema.parse(input.type);
162
+ const serializedPayload = JSON.stringify(input.payload ?? null);
163
+ const payloadBytes = Buffer.byteLength(serializedPayload, "utf8");
164
+ invariant(payloadBytes <= this.maxPayloadSizeBytes, "PAYLOAD_TOO_LARGE", `Message payload size (${payloadBytes} bytes) exceeds maximum allowed size of ${this.maxPayloadSizeBytes} bytes`);
165
+ const references = input.references ?? [];
166
+ const riskTags = [...new Set(input.riskTags ?? [])].sort();
167
+ if (input.taskId) {
168
+ const task = this.database.getTask(input.taskId);
169
+ invariant(task, "TASK_NOT_FOUND", `Task not found: ${input.taskId}`);
170
+ this.assertTaskParticipant(task, callerRole);
171
+ }
172
+ if (input.inReplyTo) {
173
+ const parent = this.database.getMessage(input.inReplyTo);
174
+ invariant(parent, "MESSAGE_NOT_FOUND", `Reply target not found: ${input.inReplyTo}`);
175
+ invariant(parent.taskId === (input.taskId ?? null), "INVALID_REPLY", "Reply target must belong to the same task");
176
+ }
177
+ for (const artifactId of references) {
178
+ const artifact = this.database.getArtifact(artifactId, false);
179
+ invariant(artifact, "ARTIFACT_NOT_FOUND", `Artifact not found: ${artifactId}`);
180
+ this.assertArtifactVisible(artifact, callerRole);
181
+ this.assertArtifactVisible(artifact, input.toRole);
182
+ }
183
+ const decision = evaluatePolicy(this.database.listPolicies(), {
184
+ subject: "message",
185
+ fromRole: callerRole,
186
+ toRole: input.toRole,
187
+ messageType: input.type,
188
+ riskTags,
189
+ });
190
+ invariant(decision.action !== "delegate_to_role", "UNSUPPORTED_POLICY", "delegate_to_role policies are not implemented in v0");
191
+ const timestamp = this.timestamp();
192
+ const status = decision.action === "auto_approve" ? "delivered" : "pending_approval";
193
+ const message = {
194
+ messageId: this.id("msg"),
195
+ taskId: input.taskId ?? null,
196
+ fromRole: callerRole,
197
+ toRole: input.toRole,
198
+ type: input.type,
199
+ payload: input.payload,
200
+ references,
201
+ inReplyTo: input.inReplyTo ?? null,
202
+ status,
203
+ riskTags,
204
+ createdAt: timestamp,
205
+ resolvedAt: status === "delivered" ? timestamp : null,
206
+ };
207
+ let approvalId;
208
+ let assignedTask;
209
+ this.database.transaction(() => {
210
+ this.database.insertMessage(message);
211
+ this.database.insertMessageEvent({
212
+ id: this.id("evt"),
213
+ messageId: message.messageId,
214
+ status,
215
+ actor: decision.action === "auto_approve" ? "policy_engine" : callerRole,
216
+ note: decision.matchedRuleId ? `Matched policy ${decision.matchedRuleId}` : "Default fail-closed policy",
217
+ createdAt: timestamp,
218
+ });
219
+ if (status === "pending_approval") {
220
+ approvalId = this.id("apr");
221
+ this.database.insertApproval({
222
+ approvalId,
223
+ subject: "message",
224
+ subjectId: message.messageId,
225
+ requestedBy: callerRole,
226
+ status: "pending",
227
+ context: {
228
+ fromRole: callerRole,
229
+ toRole: input.toRole,
230
+ type: input.type,
231
+ payload: input.payload,
232
+ taskId: input.taskId ?? null,
233
+ references,
234
+ riskTags,
235
+ matchedRuleId: decision.matchedRuleId,
236
+ },
237
+ createdAt: timestamp,
238
+ decidedAt: null,
239
+ decisionNote: null,
240
+ });
241
+ }
242
+ else {
243
+ assignedTask = this.assignTaskForApprovedProposal(message, timestamp);
244
+ }
245
+ });
246
+ this.emitDomainEvent("message_created", message);
247
+ if (assignedTask) {
248
+ this.emitDomainEvent("task_status_changed", {
249
+ taskId: assignedTask.taskId,
250
+ status: "assigned",
251
+ task: assignedTask,
252
+ });
253
+ }
254
+ if (status === "pending_approval" && approvalId) {
255
+ this.emitDomainEvent("approval_created", {
256
+ approvalId,
257
+ subject: "message",
258
+ subjectId: message.messageId,
259
+ requestedBy: callerRole,
260
+ });
261
+ }
262
+ return message;
263
+ });
264
+ }
265
+ getInboxPaginated(callerRole, options) {
266
+ this.touchPresence(callerRole);
267
+ return this.database.listInboxPaginated(callerRole, options);
268
+ }
269
+ getInbox(callerRole, options) {
270
+ this.touchPresence(callerRole);
271
+ return this.database.listInbox(callerRole, options);
272
+ }
273
+ getWorkQueue(callerRole) {
274
+ this.touchPresence(callerRole);
275
+ const unreadMessages = this.getInbox(callerRole);
276
+ const activeTasks = this.listTasks(callerRole).filter((task) => !["completed", "failed", "cancelled"].includes(task.status));
277
+ const nextActions = [];
278
+ const handoffTaskIds = new Set();
279
+ for (const message of unreadMessages) {
280
+ const task = message.taskId ? this.database.getTask(message.taskId) : undefined;
281
+ if (message.type === "proposal" && task && task.assignedTo === callerRole &&
282
+ task.status === "assigned") {
283
+ handoffTaskIds.add(task.taskId);
284
+ nextActions.push({
285
+ kind: "accept_handoff",
286
+ priority: 100,
287
+ reason: `Approved proposal from ${message.fromRole} is ready to start`,
288
+ messageId: message.messageId,
289
+ taskId: task.taskId,
290
+ suggestedTool: {
291
+ name: "accept_handoff",
292
+ arguments: { message_id: message.messageId },
293
+ },
294
+ });
295
+ }
296
+ else {
297
+ nextActions.push({
298
+ kind: "acknowledge_message",
299
+ priority: 80,
300
+ reason: `Unread ${message.type} from ${message.fromRole}`,
301
+ messageId: message.messageId,
302
+ ...(message.taskId ? { taskId: message.taskId } : {}),
303
+ suggestedTool: {
304
+ name: "acknowledge_message",
305
+ arguments: { message_id: message.messageId },
306
+ },
307
+ });
308
+ }
309
+ }
310
+ for (const task of activeTasks) {
311
+ if (task.assignedTo === callerRole && task.status === "assigned" && !handoffTaskIds.has(task.taskId)) {
312
+ nextActions.push({
313
+ kind: "start_task",
314
+ priority: 70,
315
+ reason: "Assigned task is ready to start",
316
+ taskId: task.taskId,
317
+ suggestedTool: {
318
+ name: "update_task_status",
319
+ arguments: { task_id: task.taskId, new_status: "in_progress", risk_tags: [] },
320
+ },
321
+ });
322
+ }
323
+ else if (task.assignedTo === callerRole && task.status === "blocked") {
324
+ nextActions.push({
325
+ kind: "resolve_blocker",
326
+ priority: 90,
327
+ reason: "Task is blocked; report or resolve the blocker before resuming",
328
+ taskId: task.taskId,
329
+ });
330
+ }
331
+ else if (task.assignedTo === callerRole && task.status === "in_progress") {
332
+ nextActions.push({
333
+ kind: "continue_task",
334
+ priority: 50,
335
+ reason: "Continue implementation or submit the task for review",
336
+ taskId: task.taskId,
337
+ });
338
+ }
339
+ else if (task.createdBy === callerRole && task.status === "proposed" && task.assignedTo) {
340
+ const hasOpenProposal = this.database.listThread(task.taskId, callerRole).some((message) => message.type === "proposal" && message.toRole === task.assignedTo &&
341
+ !["rejected"].includes(message.status));
342
+ if (hasOpenProposal)
343
+ continue;
344
+ nextActions.push({
345
+ kind: "send_proposal",
346
+ priority: 60,
347
+ reason: `Task is waiting for an approved proposal to ${task.assignedTo}`,
348
+ taskId: task.taskId,
349
+ suggestedTool: {
350
+ name: "send_message",
351
+ arguments: {
352
+ to_role: task.assignedTo,
353
+ type: "proposal",
354
+ task_id: task.taskId,
355
+ payload: { objective: task.description ?? task.title },
356
+ references: [],
357
+ risk_tags: [],
358
+ },
359
+ },
360
+ });
361
+ }
362
+ else if (task.createdBy === callerRole && task.status === "awaiting_review") {
363
+ nextActions.push({
364
+ kind: "review_task",
365
+ priority: 70,
366
+ reason: "Implementation is awaiting your review",
367
+ taskId: task.taskId,
368
+ });
369
+ }
370
+ }
371
+ nextActions.sort((a, b) => b.priority - a.priority);
372
+ const presence = this.database.listRolePresence();
373
+ return {
374
+ roleId: callerRole,
375
+ generatedAt: this.timestamp(),
376
+ summary: {
377
+ unreadMessages: unreadMessages.length,
378
+ activeTasks: activeTasks.length,
379
+ blockedTasks: activeTasks.filter((task) => task.status === "blocked").length,
380
+ },
381
+ unreadMessages,
382
+ activeTasks,
383
+ nextActions,
384
+ presence,
385
+ };
386
+ }
387
+ acknowledgeMessage(callerRole, messageId) {
388
+ this.touchPresence(callerRole);
389
+ const message = this.database.getMessage(messageId);
390
+ invariant(message, "MESSAGE_NOT_FOUND", `Message not found: ${messageId}`);
391
+ invariant(message.toRole === callerRole, "FORBIDDEN", "Only the recipient can acknowledge a message");
392
+ if (message.status === "acknowledged")
393
+ return message;
394
+ invariant(message.status === "delivered" || message.status === "approved", "INVALID_MESSAGE_STATE", `Cannot acknowledge a message in ${message.status} state`);
395
+ const timestamp = this.timestamp();
396
+ this.database.transaction(() => {
397
+ this.database.updateMessage(messageId, "acknowledged", timestamp);
398
+ this.database.insertMessageEvent({
399
+ id: this.id("evt"), messageId, status: "acknowledged", actor: callerRole,
400
+ note: null, createdAt: timestamp,
401
+ });
402
+ });
403
+ this.emitDomainEvent("message_status_changed", { messageId, status: "acknowledged", role: callerRole });
404
+ return this.database.getMessage(messageId);
405
+ }
406
+ acceptHandoff(callerRole, messageId, idempotencyKey) {
407
+ return this.executeIdempotent(callerRole, "acceptHandoff", idempotencyKey, { messageId }, () => {
408
+ const message = this.database.getMessage(messageId);
409
+ invariant(message, "MESSAGE_NOT_FOUND", `Message not found: ${messageId}`);
410
+ invariant(message.toRole === callerRole, "FORBIDDEN", "Only the recipient can accept a handoff");
411
+ invariant(message.type === "proposal", "INVALID_HANDOFF", "A handoff must be a proposal message");
412
+ invariant(message.taskId, "INVALID_HANDOFF", "A handoff proposal must reference a task");
413
+ invariant(["approved", "delivered", "acknowledged"].includes(message.status), "INVALID_MESSAGE_STATE", "The proposal must be approved and delivered before it can be accepted");
414
+ const task = this.database.getTask(message.taskId);
415
+ invariant(task, "TASK_NOT_FOUND", `Task not found: ${message.taskId}`);
416
+ invariant(task.assignedTo === callerRole, "FORBIDDEN", "The task is assigned to another role");
417
+ const acknowledged = this.acknowledgeMessage(callerRole, messageId);
418
+ if (!["proposed", "assigned"].includes(task.status)) {
419
+ return { message: acknowledged, task, pendingApproval: false };
420
+ }
421
+ invariant(task.status === "assigned", "INVALID_HANDOFF", `Cannot accept a task in ${task.status} state`);
422
+ const started = this.updateTaskStatus(callerRole, task.taskId, "in_progress");
423
+ return { message: acknowledged, task: started.task, pendingApproval: started.pendingApproval };
424
+ });
425
+ }
426
+ getThreadPaginated(callerRole, taskId, options) {
427
+ this.touchPresence(callerRole);
428
+ const task = this.database.getTask(taskId);
429
+ invariant(task, "TASK_NOT_FOUND", `Task not found: ${taskId}`);
430
+ this.assertTaskParticipant(task, callerRole);
431
+ return this.database.listThreadPaginated(taskId, callerRole, options);
432
+ }
433
+ getThread(callerRole, taskId, options) {
434
+ this.touchPresence(callerRole);
435
+ const task = this.database.getTask(taskId);
436
+ invariant(task, "TASK_NOT_FOUND", `Task not found: ${taskId}`);
437
+ this.assertTaskParticipant(task, callerRole);
438
+ return this.database.listThread(taskId, callerRole, options);
439
+ }
440
+ createArtifact(callerRole, input) {
441
+ return this.executeIdempotent(callerRole, "createArtifact", input.idempotencyKey, input, () => {
442
+ const role = this.role(callerRole);
443
+ invariant((input.content === undefined) !== (input.contentUri === undefined), "INVALID_ARTIFACT", "Provide exactly one of content or contentUri");
444
+ if (input.content !== undefined) {
445
+ const contentBytes = Buffer.byteLength(input.content, "utf8");
446
+ invariant(contentBytes <= this.maxArtifactSizeBytes, "ARTIFACT_TOO_LARGE", `Artifact content size (${contentBytes} bytes) exceeds maximum allowed size of ${this.maxArtifactSizeBytes} bytes`);
447
+ }
448
+ if (input.relatedTaskId) {
449
+ const task = this.database.getTask(input.relatedTaskId);
450
+ invariant(task, "TASK_NOT_FOUND", `Task not found: ${input.relatedTaskId}`);
451
+ this.assertTaskParticipant(task, callerRole);
452
+ }
453
+ const visibility = input.visibleToRoles ?? role.artifact_visibility;
454
+ if (visibility !== "all") {
455
+ for (const roleId of visibility)
456
+ this.role(roleId);
457
+ invariant(visibility.includes(callerRole), "INVALID_VISIBILITY", "The producing role must retain visibility to its artifact");
458
+ }
459
+ const contentValue = input.content ?? input.contentUri;
460
+ const artifact = {
461
+ artifactId: this.id("art"),
462
+ type: input.type,
463
+ name: input.name,
464
+ producedBy: callerRole,
465
+ ...(input.content === undefined ? {} : { content: input.content }),
466
+ ...(input.contentUri === undefined ? {} : { contentUri: input.contentUri }),
467
+ contentHash: createHash("sha256").update(contentValue).digest("hex"),
468
+ visibleToRoles: visibility,
469
+ relatedTaskId: input.relatedTaskId ?? null,
470
+ createdAt: this.timestamp(),
471
+ };
472
+ this.database.insertArtifact(artifact);
473
+ this.emitDomainEvent("artifact_created", artifact);
474
+ return artifact;
475
+ });
476
+ }
477
+ hasRemainingVisibleArtifact(callerRole, taskId, afterTime, afterId) {
478
+ let cursor = encodeCursor(afterTime, afterId);
479
+ while (cursor) {
480
+ const page = this.database.listArtifactsPaginated(taskId, {
481
+ limit: 50,
482
+ cursor,
483
+ });
484
+ for (const art of page.items) {
485
+ if (this.canSeeArtifact(art, callerRole)) {
486
+ return true;
487
+ }
488
+ }
489
+ cursor = page.nextCursor ?? undefined;
490
+ }
491
+ return false;
492
+ }
493
+ listArtifactsPaginated(callerRole, taskIdOrOptions, maybeOptions) {
494
+ this.touchPresence(callerRole);
495
+ let taskId = null;
496
+ let options;
497
+ if (typeof taskIdOrOptions === "string") {
498
+ taskId = taskIdOrOptions;
499
+ options = maybeOptions;
500
+ }
501
+ else if (taskIdOrOptions && typeof taskIdOrOptions === "object") {
502
+ options = taskIdOrOptions;
503
+ }
504
+ else {
505
+ options = maybeOptions;
506
+ }
507
+ if (taskId) {
508
+ const task = this.database.getTask(taskId);
509
+ invariant(task, "TASK_NOT_FOUND", `Task not found: ${taskId}`);
510
+ this.assertTaskParticipant(task, callerRole);
511
+ }
512
+ const targetLimit = this.database.resolveLimit(options?.limit);
513
+ const collected = [];
514
+ let currentCursor = options?.cursor;
515
+ let nextCursor = null;
516
+ // Scan and fill until targetLimit visible items are collected or no more rows exist in DB
517
+ while (collected.length < targetLimit) {
518
+ const pageResult = this.database.listArtifactsPaginated(taskId, {
519
+ limit: Math.max(targetLimit, 20),
520
+ cursor: currentCursor,
521
+ });
522
+ if (pageResult.items.length === 0) {
523
+ nextCursor = null;
524
+ break;
525
+ }
526
+ for (let i = 0; i < pageResult.items.length; i++) {
527
+ const artifact = pageResult.items[i];
528
+ if (this.canSeeArtifact(artifact, callerRole)) {
529
+ collected.push(artifact);
530
+ if (collected.length === targetLimit) {
531
+ // Check if any visible artifact remains after this item
532
+ const remainingInBatch = pageResult.items.slice(i + 1);
533
+ const hasVisibleInBatch = remainingInBatch.some((a) => this.canSeeArtifact(a, callerRole));
534
+ if (hasVisibleInBatch) {
535
+ nextCursor = encodeCursor(artifact.createdAt, artifact.artifactId);
536
+ }
537
+ else if (pageResult.nextCursor) {
538
+ const hasMoreVisible = this.hasRemainingVisibleArtifact(callerRole, taskId, artifact.createdAt, artifact.artifactId);
539
+ nextCursor = hasMoreVisible ? encodeCursor(artifact.createdAt, artifact.artifactId) : null;
540
+ }
541
+ else {
542
+ nextCursor = null;
543
+ }
544
+ break;
545
+ }
546
+ }
547
+ }
548
+ if (collected.length === targetLimit) {
549
+ break;
550
+ }
551
+ if (!pageResult.nextCursor) {
552
+ nextCursor = null;
553
+ break;
554
+ }
555
+ currentCursor = pageResult.nextCursor;
556
+ }
557
+ return {
558
+ items: collected,
559
+ nextCursor,
560
+ };
561
+ }
562
+ listArtifacts(callerRole, taskIdOrOptions, maybeOptions) {
563
+ return this.listArtifactsPaginated(callerRole, taskIdOrOptions, maybeOptions).items;
564
+ }
565
+ getArtifact(callerRole, artifactId) {
566
+ this.touchPresence(callerRole);
567
+ const artifact = this.database.getArtifact(artifactId, true);
568
+ invariant(artifact, "ARTIFACT_NOT_FOUND", `Artifact not found: ${artifactId}`);
569
+ this.assertArtifactVisible(artifact, callerRole);
570
+ return artifact;
571
+ }
572
+ updateTaskStatus(callerRole, taskId, requestedStatus, riskTags = [], idempotencyKey) {
573
+ return this.executeIdempotent(callerRole, "updateTaskStatus", idempotencyKey, { taskId, requestedStatus, riskTags }, () => {
574
+ this.role(callerRole);
575
+ const task = this.database.getTask(taskId);
576
+ invariant(task, "TASK_NOT_FOUND", `Task not found: ${taskId}`);
577
+ this.assertTaskParticipant(task, callerRole);
578
+ const toStatus = TaskStatusSchema.parse(requestedStatus);
579
+ invariant(TASK_TRANSITIONS[task.status].includes(toStatus), "INVALID_TASK_TRANSITION", `Task cannot transition from ${task.status} to ${toStatus}`);
580
+ if (this.database.hasPendingTransition(taskId, toStatus, callerRole)) {
581
+ return { task, pendingApproval: true };
582
+ }
583
+ const decision = evaluatePolicy(this.database.listPolicies(), {
584
+ subject: "task",
585
+ fromRole: callerRole,
586
+ ...(task.assignedTo ? { toRole: task.assignedTo } : {}),
587
+ fromStatus: task.status,
588
+ toStatus,
589
+ riskTags: [...new Set(riskTags)].sort(),
590
+ });
591
+ invariant(decision.action !== "delegate_to_role", "UNSUPPORTED_POLICY", "delegate_to_role policies are not implemented in v0");
592
+ const timestamp = this.timestamp();
593
+ if (decision.action === "auto_approve") {
594
+ this.database.updateTaskStatus(taskId, toStatus, timestamp);
595
+ const updated = this.database.getTask(taskId);
596
+ this.emitDomainEvent("task_status_changed", { taskId, status: toStatus, task: updated });
597
+ return { task: updated, pendingApproval: false };
598
+ }
599
+ const transitionId = this.id("trn");
600
+ const approvalId = this.id("apr");
601
+ this.database.transaction(() => {
602
+ this.database.insertTransition({
603
+ id: transitionId,
604
+ taskId,
605
+ fromStatus: task.status,
606
+ toStatus,
607
+ requestedBy: callerRole,
608
+ status: "pending",
609
+ createdAt: timestamp,
610
+ });
611
+ this.database.insertApproval({
612
+ approvalId,
613
+ subject: "task",
614
+ subjectId: transitionId,
615
+ requestedBy: callerRole,
616
+ status: "pending",
617
+ context: { taskId, fromStatus: task.status, toStatus, matchedRuleId: decision.matchedRuleId },
618
+ createdAt: timestamp,
619
+ decidedAt: null,
620
+ decisionNote: null,
621
+ });
622
+ });
623
+ this.emitDomainEvent("approval_created", {
624
+ approvalId,
625
+ subject: "task",
626
+ subjectId: transitionId,
627
+ taskId,
628
+ fromStatus: task.status,
629
+ toStatus,
630
+ });
631
+ return { task, pendingApproval: true };
632
+ });
633
+ }
634
+ listPendingApprovals() {
635
+ return this.database.listPendingApprovals();
636
+ }
637
+ approve(approvalId, note, editedPayload) {
638
+ const approval = this.database.getApproval(approvalId);
639
+ invariant(approval, "APPROVAL_NOT_FOUND", `Approval not found: ${approvalId}`);
640
+ invariant(approval.status === "pending", "APPROVAL_RESOLVED", "Approval has already been resolved");
641
+ const timestamp = this.timestamp();
642
+ let assignedTask;
643
+ let transitionedTask;
644
+ this.database.transaction(() => {
645
+ if (approval.subject === "message") {
646
+ const message = this.database.getMessage(approval.subjectId);
647
+ invariant(message, "MESSAGE_NOT_FOUND", `Message not found: ${approval.subjectId}`);
648
+ invariant(message.status === "pending_approval", "INVALID_MESSAGE_STATE", "Message is not pending approval");
649
+ if (editedPayload !== undefined) {
650
+ this.database.updateMessage(message.messageId, "edited", null, editedPayload);
651
+ this.database.insertMessageEvent({
652
+ id: this.id("evt"), messageId: message.messageId, status: "edited", actor: "human",
653
+ note: note ?? null, createdAt: timestamp,
654
+ });
655
+ }
656
+ this.database.updateMessage(message.messageId, "delivered", timestamp, editedPayload);
657
+ this.database.insertMessageEvent({
658
+ id: this.id("evt"), messageId: message.messageId, status: "delivered", actor: "human",
659
+ note: note ?? null, createdAt: timestamp,
660
+ });
661
+ assignedTask = this.assignTaskForApprovedProposal(message, timestamp);
662
+ }
663
+ else {
664
+ invariant(editedPayload === undefined, "INVALID_APPROVAL_EDIT", "Task transitions cannot edit message payloads");
665
+ const transition = this.database.getTransition(approval.subjectId);
666
+ invariant(transition, "TRANSITION_NOT_FOUND", `Transition not found: ${approval.subjectId}`);
667
+ const task = this.database.getTask(String(transition.task_id));
668
+ invariant(task, "TASK_NOT_FOUND", `Task not found: ${String(transition.task_id)}`);
669
+ invariant(task.status === String(transition.from_status), "STALE_TRANSITION", `Task state changed from ${String(transition.from_status)} to ${task.status} while approval was pending`);
670
+ this.database.updateTaskStatus(task.taskId, String(transition.to_status), timestamp);
671
+ this.database.resolveTransition(approval.subjectId, "approved", timestamp);
672
+ transitionedTask = this.database.getTask(task.taskId);
673
+ }
674
+ this.database.resolveApproval(approvalId, "approved", timestamp, note ?? null, editedPayload);
675
+ });
676
+ this.emitDomainEvent("approval_resolved", { approvalId, status: "approved", subject: approval.subject });
677
+ if (approval.subject === "message") {
678
+ this.emitDomainEvent("message_status_changed", { messageId: approval.subjectId, status: "delivered" });
679
+ if (assignedTask) {
680
+ this.emitDomainEvent("task_status_changed", {
681
+ taskId: assignedTask.taskId,
682
+ status: "assigned",
683
+ task: assignedTask,
684
+ });
685
+ }
686
+ }
687
+ else {
688
+ this.emitDomainEvent("task_status_changed", {
689
+ transitionId: approval.subjectId,
690
+ taskId: transitionedTask?.taskId,
691
+ status: transitionedTask?.status,
692
+ task: transitionedTask,
693
+ });
694
+ }
695
+ return this.database.getApproval(approvalId);
696
+ }
697
+ reject(approvalId, note) {
698
+ const approval = this.database.getApproval(approvalId);
699
+ invariant(approval, "APPROVAL_NOT_FOUND", `Approval not found: ${approvalId}`);
700
+ invariant(approval.status === "pending", "APPROVAL_RESOLVED", "Approval has already been resolved");
701
+ const timestamp = this.timestamp();
702
+ this.database.transaction(() => {
703
+ if (approval.subject === "message") {
704
+ const message = this.database.getMessage(approval.subjectId);
705
+ invariant(message, "MESSAGE_NOT_FOUND", `Message not found: ${approval.subjectId}`);
706
+ this.database.updateMessage(message.messageId, "rejected", timestamp);
707
+ this.database.insertMessageEvent({
708
+ id: this.id("evt"), messageId: message.messageId, status: "rejected", actor: "human",
709
+ note: note ?? null, createdAt: timestamp,
710
+ });
711
+ }
712
+ else {
713
+ this.database.resolveTransition(approval.subjectId, "rejected", timestamp);
714
+ }
715
+ this.database.resolveApproval(approvalId, "rejected", timestamp, note ?? null);
716
+ });
717
+ this.emitDomainEvent("approval_resolved", { approvalId, status: "rejected", subject: approval.subject });
718
+ if (approval.subject === "message") {
719
+ this.emitDomainEvent("message_status_changed", { messageId: approval.subjectId, status: "rejected" });
720
+ }
721
+ return this.database.getApproval(approvalId);
722
+ }
723
+ listPolicies() {
724
+ return this.database.listPolicies();
725
+ }
726
+ savePolicy(input) {
727
+ const parsed = InitialPolicySchema.parse(input);
728
+ invariant(parsed.action !== "delegate_to_role", "UNSUPPORTED_POLICY", "delegate_to_role policies are not implemented in v0");
729
+ if (parsed.from_role)
730
+ this.role(parsed.from_role);
731
+ if (parsed.to_role)
732
+ this.role(parsed.to_role);
733
+ if (parsed.delegate_role)
734
+ this.role(parsed.delegate_role);
735
+ const timestamp = this.timestamp();
736
+ const id = parsed.id ?? this.id("pol");
737
+ const existing = this.database.getPolicy(id);
738
+ const policy = {
739
+ ...parsed,
740
+ id,
741
+ enabled: existing?.enabled ?? true,
742
+ createdAt: existing?.createdAt ?? timestamp,
743
+ updatedAt: timestamp,
744
+ };
745
+ this.database.upsertPolicy(policy);
746
+ this.emitDomainEvent("policy_saved", policy);
747
+ return policy;
748
+ }
749
+ setPolicyEnabled(policyId, enabled) {
750
+ const policy = this.database.getPolicy(policyId);
751
+ invariant(policy, "POLICY_NOT_FOUND", `Policy not found: ${policyId}`);
752
+ this.database.setPolicyEnabled(policyId, enabled, this.timestamp());
753
+ const updated = this.database.getPolicy(policyId);
754
+ this.emitDomainEvent("policy_toggled", { policyId, enabled, policy: updated });
755
+ return updated;
756
+ }
757
+ assertRoute(fromRole, toRole) {
758
+ const sender = this.role(fromRole);
759
+ this.role(toRole);
760
+ invariant(sender.allowed_peers.includes(toRole), "ROUTE_FORBIDDEN", `Role ${fromRole} is not allowed to send to ${toRole}`);
761
+ }
762
+ assertTaskParticipant(task, roleId) {
763
+ const involved = task.createdBy === roleId ||
764
+ (task.assignedTo === roleId && task.status !== "proposed") ||
765
+ this.database.listThread(task.taskId, roleId).length > 0;
766
+ invariant(involved, "FORBIDDEN", `Role ${roleId} is not a participant in task ${task.taskId}`);
767
+ }
768
+ assignTaskForApprovedProposal(message, timestamp) {
769
+ if (message.type !== "proposal" || !message.taskId)
770
+ return undefined;
771
+ const task = this.database.getTask(message.taskId);
772
+ if (!task || task.status !== "proposed" || task.assignedTo !== message.toRole)
773
+ return undefined;
774
+ const transitionId = this.id("trn");
775
+ this.database.insertTransition({
776
+ id: transitionId,
777
+ taskId: task.taskId,
778
+ fromStatus: "proposed",
779
+ toStatus: "assigned",
780
+ requestedBy: message.fromRole,
781
+ status: "approved",
782
+ createdAt: timestamp,
783
+ });
784
+ this.database.resolveTransition(transitionId, "approved", timestamp);
785
+ this.database.updateTaskStatus(task.taskId, "assigned", timestamp);
786
+ return this.database.getTask(task.taskId);
787
+ }
788
+ prune(options) {
789
+ const result = this.database.pruneHistory(options);
790
+ if (!result.dryRun) {
791
+ this.emitDomainEvent("history_pruned", result);
792
+ }
793
+ return result;
794
+ }
795
+ checkpointAndCompact() {
796
+ return this.database.checkpointAndCompact();
797
+ }
798
+ canSeeArtifact(artifact, roleId) {
799
+ return artifact.visibleToRoles === "all" || artifact.visibleToRoles.includes(roleId);
800
+ }
801
+ assertArtifactVisible(artifact, roleId) {
802
+ invariant(this.canSeeArtifact(artifact, roleId), "ARTIFACT_FORBIDDEN", `Role ${roleId} cannot access artifact ${artifact.artifactId}`);
803
+ }
804
+ }
805
+ //# sourceMappingURL=broker.js.map