@animalabs/agent-framework 0.6.6 → 0.6.8

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 (117) hide show
  1. package/README.md +78 -0
  2. package/dist/src/agent.d.ts +5 -1
  3. package/dist/src/agent.d.ts.map +1 -1
  4. package/dist/src/agent.js +35 -1
  5. package/dist/src/agent.js.map +1 -1
  6. package/dist/src/api/mcp-server.js +0 -0
  7. package/dist/src/api/server.d.ts.map +1 -1
  8. package/dist/src/api/server.js +7 -2
  9. package/dist/src/api/server.js.map +1 -1
  10. package/dist/src/framework.d.ts +91 -3
  11. package/dist/src/framework.d.ts.map +1 -1
  12. package/dist/src/framework.js +988 -82
  13. package/dist/src/framework.js.map +1 -1
  14. package/dist/src/gate/event-gate.d.ts.map +1 -1
  15. package/dist/src/gate/event-gate.js +9 -2
  16. package/dist/src/gate/event-gate.js.map +1 -1
  17. package/dist/src/index.d.ts +4 -0
  18. package/dist/src/index.d.ts.map +1 -1
  19. package/dist/src/index.js +3 -0
  20. package/dist/src/index.js.map +1 -1
  21. package/dist/src/mcpl/channel-registry.d.ts +3 -1
  22. package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
  23. package/dist/src/mcpl/channel-registry.js +29 -15
  24. package/dist/src/mcpl/channel-registry.js.map +1 -1
  25. package/dist/src/mcpl/server-connection.d.ts +36 -3
  26. package/dist/src/mcpl/server-connection.d.ts.map +1 -1
  27. package/dist/src/mcpl/server-connection.js +142 -28
  28. package/dist/src/mcpl/server-connection.js.map +1 -1
  29. package/dist/src/modules/workspace/index.d.ts +12 -1
  30. package/dist/src/modules/workspace/index.d.ts.map +1 -1
  31. package/dist/src/modules/workspace/index.js +661 -2
  32. package/dist/src/modules/workspace/index.js.map +1 -1
  33. package/dist/src/modules/workspace/types.d.ts +4 -0
  34. package/dist/src/modules/workspace/types.d.ts.map +1 -1
  35. package/dist/src/modules/workspace/types.js.map +1 -1
  36. package/dist/src/recovery/discord-awareness-outbox.d.ts +99 -0
  37. package/dist/src/recovery/discord-awareness-outbox.d.ts.map +1 -0
  38. package/dist/src/recovery/discord-awareness-outbox.js +334 -0
  39. package/dist/src/recovery/discord-awareness-outbox.js.map +1 -0
  40. package/dist/src/recovery/offline-branch.d.ts +45 -0
  41. package/dist/src/recovery/offline-branch.d.ts.map +1 -0
  42. package/dist/src/recovery/offline-branch.js +362 -0
  43. package/dist/src/recovery/offline-branch.js.map +1 -0
  44. package/dist/src/recovery/recover-cli.d.ts +3 -0
  45. package/dist/src/recovery/recover-cli.d.ts.map +1 -0
  46. package/dist/src/recovery/recover-cli.js +129 -0
  47. package/dist/src/recovery/recover-cli.js.map +1 -0
  48. package/dist/src/types/agent.d.ts +10 -0
  49. package/dist/src/types/agent.d.ts.map +1 -1
  50. package/dist/src/types/framework.d.ts +14 -0
  51. package/dist/src/types/framework.d.ts.map +1 -1
  52. package/dist/src/types/module.d.ts +31 -0
  53. package/dist/src/types/module.d.ts.map +1 -1
  54. package/dist/src/types/trace.d.ts +2 -0
  55. package/dist/src/types/trace.d.ts.map +1 -1
  56. package/dist/test/agent-runtime-settings.test.js +200 -93
  57. package/dist/test/agent-runtime-settings.test.js.map +1 -1
  58. package/dist/test/busy-agent-scheduler.test.d.ts +2 -0
  59. package/dist/test/busy-agent-scheduler.test.d.ts.map +1 -0
  60. package/dist/test/busy-agent-scheduler.test.js +62 -0
  61. package/dist/test/busy-agent-scheduler.test.js.map +1 -0
  62. package/dist/test/channel-lifecycle-provenance.test.d.ts +2 -0
  63. package/dist/test/channel-lifecycle-provenance.test.d.ts.map +1 -0
  64. package/dist/test/channel-lifecycle-provenance.test.js +47 -0
  65. package/dist/test/channel-lifecycle-provenance.test.js.map +1 -0
  66. package/dist/test/discord-awareness-outbox.test.d.ts +2 -0
  67. package/dist/test/discord-awareness-outbox.test.d.ts.map +1 -0
  68. package/dist/test/discord-awareness-outbox.test.js +248 -0
  69. package/dist/test/discord-awareness-outbox.test.js.map +1 -0
  70. package/dist/test/maintenance-timer.test.js +100 -0
  71. package/dist/test/maintenance-timer.test.js.map +1 -1
  72. package/dist/test/mcpl-awareness-barrier.test.d.ts +2 -0
  73. package/dist/test/mcpl-awareness-barrier.test.d.ts.map +1 -0
  74. package/dist/test/mcpl-awareness-barrier.test.js +618 -0
  75. package/dist/test/mcpl-awareness-barrier.test.js.map +1 -0
  76. package/dist/test/mcpl-reconnect-reregistration.test.js +22 -3
  77. package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -1
  78. package/dist/test/mcpl-request-timeout.test.js +15 -0
  79. package/dist/test/mcpl-request-timeout.test.js.map +1 -1
  80. package/dist/test/offline-recovery-branch.test.d.ts +2 -0
  81. package/dist/test/offline-recovery-branch.test.d.ts.map +1 -0
  82. package/dist/test/offline-recovery-branch.test.js +226 -0
  83. package/dist/test/offline-recovery-branch.test.js.map +1 -0
  84. package/dist/test/present-while-acting.test.d.ts +3 -2
  85. package/dist/test/present-while-acting.test.d.ts.map +1 -1
  86. package/dist/test/present-while-acting.test.js +31 -3
  87. package/dist/test/present-while-acting.test.js.map +1 -1
  88. package/dist/test/refusal-single-call.test.d.ts +2 -0
  89. package/dist/test/refusal-single-call.test.d.ts.map +1 -0
  90. package/dist/test/refusal-single-call.test.js +177 -0
  91. package/dist/test/refusal-single-call.test.js.map +1 -0
  92. package/dist/test/same-round-think-policy.test.d.ts +2 -0
  93. package/dist/test/same-round-think-policy.test.d.ts.map +1 -0
  94. package/dist/test/same-round-think-policy.test.js +853 -0
  95. package/dist/test/same-round-think-policy.test.js.map +1 -0
  96. package/dist/test/workspace-read-image.test.d.ts +2 -0
  97. package/dist/test/workspace-read-image.test.d.ts.map +1 -0
  98. package/dist/test/workspace-read-image.test.js +518 -0
  99. package/dist/test/workspace-read-image.test.js.map +1 -0
  100. package/dist/tsconfig.tsbuildinfo +1 -1
  101. package/package.json +5 -3
  102. package/src/agent.ts +39 -2
  103. package/src/api/server.ts +8 -2
  104. package/src/framework.ts +1180 -79
  105. package/src/gate/event-gate.ts +15 -3
  106. package/src/index.ts +20 -0
  107. package/src/mcpl/channel-registry.ts +31 -16
  108. package/src/mcpl/server-connection.ts +163 -28
  109. package/src/modules/workspace/index.ts +759 -2
  110. package/src/modules/workspace/types.ts +5 -0
  111. package/src/recovery/discord-awareness-outbox.ts +459 -0
  112. package/src/recovery/offline-branch.ts +510 -0
  113. package/src/recovery/recover-cli.ts +130 -0
  114. package/src/types/agent.ts +14 -0
  115. package/src/types/framework.ts +17 -0
  116. package/src/types/module.ts +33 -0
  117. package/src/types/trace.ts +2 -0
@@ -133,6 +133,11 @@ export interface ReadInput {
133
133
  limit?: number;
134
134
  }
135
135
 
136
+ export interface ReadImageInput {
137
+ /** Image file path (mount-prefixed, e.g., "project/assets/logo.png") */
138
+ path: string;
139
+ }
140
+
136
141
  export interface WriteInput {
137
142
  /** File path (mount-prefixed) */
138
143
  path: string;
@@ -0,0 +1,459 @@
1
+ import {
2
+ mkdirSync,
3
+ readFileSync,
4
+ renameSync,
5
+ writeFileSync,
6
+ } from 'node:fs';
7
+ import { dirname, join } from 'node:path';
8
+ import { randomUUID } from 'node:crypto';
9
+
10
+ export const DEFAULT_DISCORD_AWARENESS_EMOJI = '💤';
11
+
12
+ export function defaultDiscordAwarenessOutboxPath(storePath: string): string {
13
+ return join(storePath, 'recovery', 'discord-awareness-outbox.json');
14
+ }
15
+
16
+ export interface DiscordAwarenessRef {
17
+ serverId: string;
18
+ channelId: string;
19
+ messageId: string;
20
+ }
21
+
22
+ export type DiscordAwarenessAction = 'add' | 'remove';
23
+ export type DiscordAwarenessDeliveryStatus = 'pending' | 'applied' | 'permanent-failure';
24
+
25
+ export interface DiscordAwarenessEntry extends DiscordAwarenessRef {
26
+ desired: boolean;
27
+ markerPresent: boolean;
28
+ deliveryStatus: DiscordAwarenessDeliveryStatus;
29
+ attempts: number;
30
+ lastAction?: DiscordAwarenessAction;
31
+ lastAttemptAt?: number;
32
+ lastError?: string;
33
+ }
34
+
35
+ export interface DiscordAwarenessBatch {
36
+ id: string;
37
+ status: 'prepared' | 'active';
38
+ agentName: string;
39
+ sourceBranch: string;
40
+ targetBranch: string;
41
+ emoji: string;
42
+ createdAt: number;
43
+ refs: DiscordAwarenessEntry[];
44
+ /**
45
+ * `target-branch` may be promoted when its target branch (or a descendant)
46
+ * is active. `explicit` requires activate() after a second mutation.
47
+ */
48
+ activationPolicy?: 'target-branch' | 'explicit';
49
+ /** Idempotent interval operations used to resume an interrupted suppression. */
50
+ suppressionIntervals?: DiscordSuppressionInterval[];
51
+ }
52
+
53
+ export interface DiscordSuppressionInterval {
54
+ fromId: string;
55
+ toId: string;
56
+ }
57
+
58
+ export interface DiscordAwarenessOperation {
59
+ batchId: string;
60
+ emoji: string;
61
+ action: DiscordAwarenessAction;
62
+ ref: DiscordAwarenessEntry;
63
+ }
64
+
65
+ interface DiscordAwarenessOutboxDocument {
66
+ version: 2;
67
+ batches: DiscordAwarenessBatch[];
68
+ }
69
+
70
+ interface BranchDescriptor {
71
+ id: string;
72
+ name: string;
73
+ parentId?: string;
74
+ }
75
+
76
+ export interface DiscordMessageMetadataCarrier {
77
+ metadata?: Record<string, unknown>;
78
+ }
79
+
80
+ /** Extract Discord addressing metadata without reading message content. */
81
+ export function extractDiscordAwarenessRefs(
82
+ messages: DiscordMessageMetadataCarrier[],
83
+ forcedServerId?: string,
84
+ ): DiscordAwarenessRef[] {
85
+ const refs = new Map<string, DiscordAwarenessRef>();
86
+
87
+ for (const message of messages) {
88
+ const metadata = message.metadata ?? {};
89
+ const channelId = typeof metadata.channelId === 'string' ? metadata.channelId : '';
90
+ const messageId = typeof metadata.messageId === 'string' ? metadata.messageId : '';
91
+ const metadataServerId = typeof metadata.serverId === 'string' ? metadata.serverId : '';
92
+ const serverId = forcedServerId ?? metadataServerId;
93
+ if (!channelId || !messageId || !serverId) continue;
94
+
95
+ const isDiscord = serverId.toLowerCase().includes('discord') || channelId.startsWith('discord:');
96
+ if (!isDiscord) continue;
97
+
98
+ const ref = { serverId, channelId, messageId };
99
+ refs.set(refKey(ref), ref);
100
+ }
101
+
102
+ return [...refs.values()];
103
+ }
104
+
105
+ /**
106
+ * A branch-independent, durable operation ledger for Discord awareness marks.
107
+ *
108
+ * Completed entries are retained. Reconciliation derives whether their emoji
109
+ * should currently exist from Chronicle branch ancestry, queuing `remove`
110
+ * after switching away from a recovery branch and `add` when returning to it.
111
+ */
112
+ export class DiscordAwarenessOutbox {
113
+ constructor(readonly path: string) {}
114
+
115
+ prepare(input: {
116
+ agentName: string;
117
+ sourceBranch: string;
118
+ targetBranch: string;
119
+ refs: DiscordAwarenessRef[];
120
+ emoji?: string;
121
+ activationPolicy?: 'target-branch' | 'explicit';
122
+ suppressionIntervals?: DiscordSuppressionInterval[];
123
+ }): DiscordAwarenessBatch | null {
124
+ const refs = dedupeRefs(input.refs);
125
+ if (refs.length === 0 && !input.suppressionIntervals?.length) return null;
126
+
127
+ const document = this.read();
128
+ const batch: DiscordAwarenessBatch = {
129
+ id: randomUUID(),
130
+ status: 'prepared',
131
+ agentName: input.agentName,
132
+ sourceBranch: input.sourceBranch,
133
+ targetBranch: input.targetBranch,
134
+ emoji: input.emoji ?? DEFAULT_DISCORD_AWARENESS_EMOJI,
135
+ createdAt: Date.now(),
136
+ refs: refs.map((ref) => ({
137
+ ...ref,
138
+ desired: false,
139
+ markerPresent: false,
140
+ deliveryStatus: 'applied',
141
+ attempts: 0,
142
+ })),
143
+ activationPolicy: input.activationPolicy ?? 'target-branch',
144
+ suppressionIntervals: input.suppressionIntervals?.map((interval) => ({ ...interval })),
145
+ };
146
+ document.batches.push(batch);
147
+ this.write(document);
148
+ return structuredClone(batch);
149
+ }
150
+
151
+ /** Activate after Chronicle has safely switched to the target branch. */
152
+ activate(batchId: string): void {
153
+ const document = this.read();
154
+ const batch = document.batches.find((candidate) => candidate.id === batchId);
155
+ if (!batch) throw new Error(`Discord awareness outbox batch not found: ${batchId}`);
156
+ batch.status = 'active';
157
+ for (const ref of batch.refs) setDesired(ref, true);
158
+ this.write(document);
159
+ }
160
+
161
+ /**
162
+ * Reconcile every retained ledger entry against the active branch. A marker
163
+ * is desired when any active batch for the same ref+emoji has a target branch
164
+ * in the active branch's ancestry.
165
+ */
166
+ reconcileForBranch(
167
+ branchName: string,
168
+ branches: BranchDescriptor[] = [],
169
+ ): { activated: number; queued: number } {
170
+ const document = this.read();
171
+ let activated = 0;
172
+
173
+ for (const batch of document.batches) {
174
+ if (
175
+ batch.status === 'prepared'
176
+ && (batch.activationPolicy ?? 'target-branch') === 'target-branch'
177
+ && isTargetActive(branchName, batch.targetBranch, branches)
178
+ ) {
179
+ batch.status = 'active';
180
+ activated++;
181
+ }
182
+ }
183
+
184
+ const desiredKeys = new Set<string>();
185
+ for (const batch of document.batches) {
186
+ if (batch.status !== 'active') continue;
187
+ if (!isTargetActive(branchName, batch.targetBranch, branches)) continue;
188
+ for (const ref of batch.refs) desiredKeys.add(markerKey(batch.emoji, ref));
189
+ }
190
+
191
+ let queued = 0;
192
+ for (const batch of document.batches) {
193
+ if (batch.status !== 'active') continue;
194
+ for (const ref of batch.refs) {
195
+ setDesired(ref, desiredKeys.has(markerKey(batch.emoji, ref)));
196
+ if (ref.deliveryStatus === 'pending') queued++;
197
+ }
198
+ }
199
+
200
+ this.write(document);
201
+ return { activated, queued };
202
+ }
203
+
204
+ /** Backward-compatible startup helper. */
205
+ activatePreparedForBranch(branchName: string, branches: BranchDescriptor[] = []): number {
206
+ return this.reconcileForBranch(branchName, branches).activated;
207
+ }
208
+
209
+ pending(serverId?: string): DiscordAwarenessOperation[] {
210
+ const operations: DiscordAwarenessOperation[] = [];
211
+ for (const batch of this.read().batches) {
212
+ if (batch.status !== 'active') continue;
213
+ for (const ref of batch.refs) {
214
+ if (ref.deliveryStatus !== 'pending') continue;
215
+ if (serverId !== undefined && ref.serverId !== serverId) continue;
216
+ operations.push({
217
+ batchId: batch.id,
218
+ emoji: batch.emoji,
219
+ action: ref.desired ? 'add' : 'remove',
220
+ ref: { ...ref },
221
+ });
222
+ }
223
+ }
224
+ return operations;
225
+ }
226
+
227
+ recordSuccess(batchId: string, ref: DiscordAwarenessRef, action: DiscordAwarenessAction): void {
228
+ const document = this.read();
229
+ const entry = findEntry(document, batchId, ref);
230
+ if (!entry) return;
231
+ entry.markerPresent = action === 'add';
232
+ entry.deliveryStatus = entry.markerPresent === entry.desired ? 'applied' : 'pending';
233
+ entry.attempts++;
234
+ entry.lastAction = action;
235
+ entry.lastAttemptAt = Date.now();
236
+ delete entry.lastError;
237
+ this.write(document);
238
+ }
239
+
240
+ recordFailure(
241
+ batchId: string,
242
+ ref: DiscordAwarenessRef,
243
+ action: DiscordAwarenessAction,
244
+ error: string,
245
+ permanent: boolean,
246
+ ): void {
247
+ const document = this.read();
248
+ const entry = findEntry(document, batchId, ref);
249
+ if (!entry) return;
250
+ entry.deliveryStatus = permanent ? 'permanent-failure' : 'pending';
251
+ entry.attempts++;
252
+ entry.lastAction = action;
253
+ entry.lastAttemptAt = Date.now();
254
+ entry.lastError = error;
255
+ this.write(document);
256
+ }
257
+
258
+ /** Compatibility alias for older callers that only acknowledged adds. */
259
+ acknowledge(batchId: string, ref: DiscordAwarenessRef): void {
260
+ this.recordSuccess(batchId, ref, 'add');
261
+ }
262
+
263
+ batches(): DiscordAwarenessBatch[] {
264
+ return structuredClone(this.read().batches);
265
+ }
266
+
267
+ preparedSuppressionsForBranch(
268
+ branchName: string,
269
+ branches: BranchDescriptor[] = [],
270
+ ): DiscordAwarenessBatch[] {
271
+ return structuredClone(this.read().batches.filter((batch) =>
272
+ batch.status === 'prepared'
273
+ && batch.activationPolicy === 'explicit'
274
+ && !!batch.suppressionIntervals?.length
275
+ && isTargetActive(branchName, batch.targetBranch, branches)));
276
+ }
277
+
278
+ private read(): DiscordAwarenessOutboxDocument {
279
+ let parsed: unknown;
280
+ try {
281
+ parsed = JSON.parse(readFileSync(this.path, 'utf8'));
282
+ } catch (error) {
283
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
284
+ return { version: 2, batches: [] };
285
+ }
286
+ throw new Error(
287
+ `Could not read Discord awareness outbox ${this.path}: ${error instanceof Error ? error.message : String(error)}`,
288
+ );
289
+ }
290
+
291
+ if (isDocumentV2(parsed)) return parsed;
292
+ if (isDocumentV1(parsed)) return migrateV1(parsed);
293
+ throw new Error(`Invalid Discord awareness outbox document: ${this.path}`);
294
+ }
295
+
296
+ private write(document: DiscordAwarenessOutboxDocument): void {
297
+ mkdirSync(dirname(this.path), { recursive: true });
298
+ const temporary = `${this.path}.${process.pid}.${randomUUID()}.tmp`;
299
+ writeFileSync(temporary, `${JSON.stringify(document, null, 2)}\n`, { mode: 0o600 });
300
+ renameSync(temporary, this.path);
301
+ }
302
+ }
303
+
304
+ function setDesired(ref: DiscordAwarenessEntry, desired: boolean): void {
305
+ const changed = ref.desired !== desired;
306
+ ref.desired = desired;
307
+ if (ref.markerPresent === desired) {
308
+ ref.deliveryStatus = 'applied';
309
+ return;
310
+ }
311
+ const action: DiscordAwarenessAction = desired ? 'add' : 'remove';
312
+ if (changed || ref.lastAction !== action || ref.deliveryStatus !== 'permanent-failure') {
313
+ ref.deliveryStatus = 'pending';
314
+ }
315
+ }
316
+
317
+ function isTargetActive(
318
+ activeName: string,
319
+ targetName: string,
320
+ branches: BranchDescriptor[],
321
+ ): boolean {
322
+ if (activeName === targetName) return true;
323
+ const byName = new Map(branches.map((branch) => [branch.name, branch]));
324
+ const byId = new Map(branches.map((branch) => [branch.id, branch]));
325
+ let cursor = byName.get(activeName);
326
+ const visited = new Set<string>();
327
+ while (cursor?.parentId && !visited.has(cursor.id)) {
328
+ visited.add(cursor.id);
329
+ cursor = byId.get(cursor.parentId);
330
+ if (cursor?.name === targetName) return true;
331
+ }
332
+ return false;
333
+ }
334
+
335
+ function findEntry(
336
+ document: DiscordAwarenessOutboxDocument,
337
+ batchId: string,
338
+ ref: DiscordAwarenessRef,
339
+ ): DiscordAwarenessEntry | undefined {
340
+ return document.batches
341
+ .find((batch) => batch.id === batchId)
342
+ ?.refs.find((candidate) => refKey(candidate) === refKey(ref));
343
+ }
344
+
345
+ function dedupeRefs(refs: DiscordAwarenessRef[]): DiscordAwarenessRef[] {
346
+ const deduped = new Map<string, DiscordAwarenessRef>();
347
+ for (const ref of refs) {
348
+ if (!ref.serverId || !ref.channelId || !ref.messageId) continue;
349
+ deduped.set(refKey(ref), { ...ref });
350
+ }
351
+ return [...deduped.values()];
352
+ }
353
+
354
+ function refKey(ref: DiscordAwarenessRef): string {
355
+ return `${ref.serverId}\0${ref.channelId}\0${ref.messageId}`;
356
+ }
357
+
358
+ function markerKey(emoji: string, ref: DiscordAwarenessRef): string {
359
+ return `${emoji}\0${refKey(ref)}`;
360
+ }
361
+
362
+ function isEntry(value: unknown): value is DiscordAwarenessEntry {
363
+ if (!value || typeof value !== 'object') return false;
364
+ const entry = value as Partial<DiscordAwarenessEntry>;
365
+ return typeof entry.serverId === 'string'
366
+ && typeof entry.channelId === 'string'
367
+ && typeof entry.messageId === 'string'
368
+ && typeof entry.desired === 'boolean'
369
+ && typeof entry.markerPresent === 'boolean'
370
+ && (entry.deliveryStatus === 'pending'
371
+ || entry.deliveryStatus === 'applied'
372
+ || entry.deliveryStatus === 'permanent-failure')
373
+ && typeof entry.attempts === 'number'
374
+ && (entry.lastAction === undefined || entry.lastAction === 'add' || entry.lastAction === 'remove')
375
+ && (entry.lastAttemptAt === undefined || typeof entry.lastAttemptAt === 'number')
376
+ && (entry.lastError === undefined || typeof entry.lastError === 'string');
377
+ }
378
+
379
+ function isDocumentV2(value: unknown): value is DiscordAwarenessOutboxDocument {
380
+ if (!value || typeof value !== 'object') return false;
381
+ const document = value as { version?: unknown; batches?: unknown };
382
+ if (document.version !== 2 || !Array.isArray(document.batches)) return false;
383
+ return document.batches.every((batch) => {
384
+ if (!batch || typeof batch !== 'object') return false;
385
+ const candidate = batch as Partial<DiscordAwarenessBatch>;
386
+ return typeof candidate.id === 'string'
387
+ && (candidate.status === 'prepared' || candidate.status === 'active')
388
+ && typeof candidate.agentName === 'string'
389
+ && typeof candidate.sourceBranch === 'string'
390
+ && typeof candidate.targetBranch === 'string'
391
+ && typeof candidate.emoji === 'string'
392
+ && typeof candidate.createdAt === 'number'
393
+ && Array.isArray(candidate.refs)
394
+ && (candidate.activationPolicy === undefined
395
+ || candidate.activationPolicy === 'target-branch'
396
+ || candidate.activationPolicy === 'explicit')
397
+ && (candidate.suppressionIntervals === undefined
398
+ || (Array.isArray(candidate.suppressionIntervals)
399
+ && candidate.suppressionIntervals.every((interval) =>
400
+ !!interval
401
+ && typeof interval.fromId === 'string'
402
+ && typeof interval.toId === 'string')))
403
+ && candidate.refs.every(isEntry);
404
+ });
405
+ }
406
+
407
+ interface LegacyDocument {
408
+ version: 1;
409
+ batches: Array<{
410
+ id: string;
411
+ status: 'prepared' | 'pending';
412
+ agentName: string;
413
+ sourceBranch: string;
414
+ targetBranch: string;
415
+ emoji: string;
416
+ createdAt: number;
417
+ refs: DiscordAwarenessRef[];
418
+ activationPolicy?: 'target-branch' | 'explicit';
419
+ }>;
420
+ }
421
+
422
+ function isDocumentV1(value: unknown): value is LegacyDocument {
423
+ if (!value || typeof value !== 'object') return false;
424
+ const document = value as { version?: unknown; batches?: unknown };
425
+ if (document.version !== 1 || !Array.isArray(document.batches)) return false;
426
+ return document.batches.every((batch) => {
427
+ if (!batch || typeof batch !== 'object') return false;
428
+ const candidate = batch as LegacyDocument['batches'][number];
429
+ return typeof candidate.id === 'string'
430
+ && (candidate.status === 'prepared' || candidate.status === 'pending')
431
+ && typeof candidate.agentName === 'string'
432
+ && typeof candidate.sourceBranch === 'string'
433
+ && typeof candidate.targetBranch === 'string'
434
+ && typeof candidate.emoji === 'string'
435
+ && typeof candidate.createdAt === 'number'
436
+ && Array.isArray(candidate.refs)
437
+ && candidate.refs.every((ref) => !!ref
438
+ && typeof ref.serverId === 'string'
439
+ && typeof ref.channelId === 'string'
440
+ && typeof ref.messageId === 'string');
441
+ });
442
+ }
443
+
444
+ function migrateV1(document: LegacyDocument): DiscordAwarenessOutboxDocument {
445
+ return {
446
+ version: 2,
447
+ batches: document.batches.map((batch) => ({
448
+ ...batch,
449
+ status: batch.status === 'pending' ? 'active' : 'prepared',
450
+ refs: batch.refs.map((ref) => ({
451
+ ...ref,
452
+ desired: batch.status === 'pending',
453
+ markerPresent: false,
454
+ deliveryStatus: batch.status === 'pending' ? 'pending' : 'applied',
455
+ attempts: 0,
456
+ })),
457
+ })),
458
+ };
459
+ }