@animalabs/agent-framework 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/framework.d.ts +13 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +133 -10
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +5 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +104 -6
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +31 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +16 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +101 -6
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -1
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +17 -2
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +66 -3
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-policy.d.ts +24 -0
- package/dist/src/mcpl/tool-policy.d.ts.map +1 -0
- package/dist/src/mcpl/tool-policy.js +46 -0
- package/dist/src/mcpl/tool-policy.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +31 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +43 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +246 -32
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +7 -2
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +2 -2
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +55 -3
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js +8 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts +40 -3
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +80 -13
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/types/trace.d.ts +20 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/src/usage/index.d.ts +3 -0
- package/dist/src/usage/index.d.ts.map +1 -0
- package/dist/src/usage/index.js +2 -0
- package/dist/src/usage/index.js.map +1 -0
- package/dist/src/usage/types.d.ts +28 -0
- package/dist/src/usage/types.d.ts.map +1 -0
- package/dist/src/usage/types.js +2 -0
- package/dist/src/usage/types.js.map +1 -0
- package/dist/src/usage/usage-tracker.d.ts +33 -0
- package/dist/src/usage/usage-tracker.d.ts.map +1 -0
- package/dist/src/usage/usage-tracker.js +115 -0
- package/dist/src/usage/usage-tracker.js.map +1 -0
- package/dist/test/channel-registry-typing.test.d.ts +2 -0
- package/dist/test/channel-registry-typing.test.d.ts.map +1 -0
- package/dist/test/channel-registry-typing.test.js +82 -0
- package/dist/test/channel-registry-typing.test.js.map +1 -0
- package/dist/test/event-gate.test.js +135 -16
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/mcpl-gate-roundtrip.test.d.ts +2 -0
- package/dist/test/mcpl-gate-roundtrip.test.d.ts.map +1 -0
- package/dist/test/mcpl-gate-roundtrip.test.js +140 -0
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -0
- package/dist/test/tool-policy.test.d.ts +2 -0
- package/dist/test/tool-policy.test.d.ts.map +1 -0
- package/dist/test/tool-policy.test.js +51 -0
- package/dist/test/tool-policy.test.js.map +1 -0
- package/dist/test/usage-tracker.test.d.ts +2 -0
- package/dist/test/usage-tracker.test.d.ts.map +1 -0
- package/dist/test/usage-tracker.test.js +129 -0
- package/dist/test/usage-tracker.test.js.map +1 -0
- package/dist/test/workspace-watcher.test.d.ts +2 -0
- package/dist/test/workspace-watcher.test.d.ts.map +1 -0
- package/dist/test/workspace-watcher.test.js +117 -0
- package/dist/test/workspace-watcher.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/framework.ts +148 -10
- package/src/gate/event-gate.ts +106 -6
- package/src/gate/types.ts +28 -0
- package/src/index.ts +7 -0
- package/src/mcpl/channel-registry.ts +120 -8
- package/src/mcpl/push-handler.ts +1 -1
- package/src/mcpl/server-connection.ts +78 -3
- package/src/mcpl/tool-policy.ts +52 -0
- package/src/mcpl/types.ts +34 -0
- package/src/modules/workspace/index.ts +269 -38
- package/src/modules/workspace/sync.ts +11 -4
- package/src/modules/workspace/types.ts +70 -3
- package/src/modules/workspace/watcher.ts +104 -15
- package/src/types/trace.ts +26 -1
- package/src/usage/index.ts +2 -0
- package/src/usage/types.ts +27 -0
- package/src/usage/usage-tracker.ts +141 -0
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
* auto-sync between real filesystem and Chronicle, and manual materialization.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFile, stat, access } from 'node:fs/promises';
|
|
9
|
-
import { join, resolve, relative } from 'node:path';
|
|
8
|
+
import { readFile, stat, access, writeFile, unlink, mkdir } from 'node:fs/promises';
|
|
9
|
+
import { join, resolve, relative, dirname } from 'node:path';
|
|
10
10
|
import type { JsStore } from '@animalabs/chronicle';
|
|
11
11
|
import type { Module, ModuleContext, ProcessState, EventResponse } from '../../types/module.js';
|
|
12
12
|
import type { ProcessEvent, ToolDefinition, ToolCall, ToolResult } from '../../types/events.js';
|
|
@@ -25,9 +25,13 @@ import type {
|
|
|
25
25
|
StatusInput,
|
|
26
26
|
MaterializeInput,
|
|
27
27
|
SyncInput,
|
|
28
|
-
|
|
28
|
+
WorkspaceCreatedEvent,
|
|
29
|
+
WorkspaceModifiedEvent,
|
|
30
|
+
WorkspaceDeletedEvent,
|
|
31
|
+
WorkspaceFsOp,
|
|
29
32
|
} from './types.js';
|
|
30
|
-
import {
|
|
33
|
+
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
34
|
+
import { MountWatcher, type FsChange } from './watcher.js';
|
|
31
35
|
import { syncFromFs, materializeToFs, hashContent, DEFAULT_MAX_FILE_SIZE, type ConflictInfo } from './sync.js';
|
|
32
36
|
|
|
33
37
|
export type {
|
|
@@ -85,6 +89,12 @@ export class WorkspaceModule implements Module {
|
|
|
85
89
|
|
|
86
90
|
/**
|
|
87
91
|
* Inject the Chronicle store. Must be called after framework creation.
|
|
92
|
+
*
|
|
93
|
+
* Host ordering is: `AgentFramework.create()` calls `module.start(ctx)`
|
|
94
|
+
* before the framework is fully returned, and the host (e.g. conhost) wires
|
|
95
|
+
* the store via `initStore(store)` afterwards. Neither half has everything
|
|
96
|
+
* it needs on its own, so watcher setup runs in whichever callback fires
|
|
97
|
+
* second. `ensureRunning()` gates on `ctx && mounts-populated`.
|
|
88
98
|
*/
|
|
89
99
|
initStore(store: JsStore): void {
|
|
90
100
|
this.store = store;
|
|
@@ -111,9 +121,13 @@ export class WorkspaceModule implements Module {
|
|
|
111
121
|
initialSyncDone: false,
|
|
112
122
|
lastMaterializedBranchId: null,
|
|
113
123
|
materializedHashes: new Map(),
|
|
124
|
+
watcherReadyAt: null,
|
|
125
|
+
watcherError: null,
|
|
114
126
|
};
|
|
115
127
|
this.mounts.set(mount.name, mountState);
|
|
116
128
|
}
|
|
129
|
+
|
|
130
|
+
this.ensureRunning();
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
async start(ctx: ModuleContext): Promise<void> {
|
|
@@ -128,24 +142,64 @@ export class WorkspaceModule implements Module {
|
|
|
128
142
|
if (mount) {
|
|
129
143
|
mount.lastMaterializedSeq = meta.lastMaterializedSeq;
|
|
130
144
|
mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
|
|
145
|
+
// watcherReadyAt intentionally not restored — each session must
|
|
146
|
+
// observe its own watcher attach, otherwise a stale timestamp
|
|
147
|
+
// would hide a new-session attach failure.
|
|
131
148
|
}
|
|
132
149
|
}
|
|
133
150
|
}
|
|
134
151
|
}
|
|
135
152
|
|
|
136
|
-
|
|
153
|
+
this.ensureRunning();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Start chokidar watchers and emit workspace:mounted events.
|
|
158
|
+
* Idempotent: safe to call from both start() and initStore(), fires once
|
|
159
|
+
* per mount regardless of which runs second.
|
|
160
|
+
*/
|
|
161
|
+
private ensureRunning(): void {
|
|
162
|
+
if (!this.ctx || this.mounts.size === 0) return;
|
|
163
|
+
|
|
137
164
|
for (const [name, mount] of this.mounts) {
|
|
165
|
+
if (this.watchers.has(name)) continue;
|
|
166
|
+
|
|
138
167
|
const watchMode = mount.config.watch ?? 'always';
|
|
139
168
|
if (watchMode === 'always') {
|
|
140
|
-
const watcher = new MountWatcher(
|
|
141
|
-
|
|
142
|
-
|
|
169
|
+
const watcher = new MountWatcher(
|
|
170
|
+
mount.config,
|
|
171
|
+
(changes) => {
|
|
172
|
+
this.handleFsChanges(name, changes);
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
onReady: () => {
|
|
176
|
+
mount.watcherReadyAt = Date.now();
|
|
177
|
+
mount.watcherError = null;
|
|
178
|
+
},
|
|
179
|
+
onError: (err) => {
|
|
180
|
+
mount.watcherError = err.message;
|
|
181
|
+
this.ctx?.pushEvent({
|
|
182
|
+
type: 'workspace:watcher-error',
|
|
183
|
+
mount: name,
|
|
184
|
+
path: mount.config.path,
|
|
185
|
+
error: err.message,
|
|
186
|
+
} as ProcessEvent);
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
);
|
|
143
190
|
watcher.start();
|
|
144
191
|
this.watchers.set(name, watcher);
|
|
192
|
+
|
|
193
|
+
// Chokidar is started with ignoreInitial:true, so files already on
|
|
194
|
+
// disk at session start would be invisible. Trigger one syncFromFs
|
|
195
|
+
// pass — syncFromFs diffs disk against the tree state, so only files
|
|
196
|
+
// new-to-this-session's-tree fire workspace:created events. Fresh
|
|
197
|
+
// sessions see the existing catalog; restarts only see what appeared
|
|
198
|
+
// while the session was down.
|
|
199
|
+
void this.initialScan(name);
|
|
145
200
|
}
|
|
146
201
|
|
|
147
|
-
|
|
148
|
-
this.ctx?.pushEvent({
|
|
202
|
+
this.ctx.pushEvent({
|
|
149
203
|
type: 'workspace:mounted',
|
|
150
204
|
mount: name,
|
|
151
205
|
path: mount.config.path,
|
|
@@ -153,6 +207,25 @@ export class WorkspaceModule implements Module {
|
|
|
153
207
|
}
|
|
154
208
|
}
|
|
155
209
|
|
|
210
|
+
private async initialScan(mountName: string): Promise<void> {
|
|
211
|
+
const store = this.store;
|
|
212
|
+
const mount = this.mounts.get(mountName);
|
|
213
|
+
if (!store || !mount) return;
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
const result = await syncFromFs(store, mount);
|
|
217
|
+
mount.initialSyncDone = true;
|
|
218
|
+
this.emitFsEvents(mountName, result.synced, result.conflicts);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
221
|
+
this.ctx?.pushEvent({
|
|
222
|
+
type: 'workspace:initial-scan-failed',
|
|
223
|
+
mount: mountName,
|
|
224
|
+
error: msg,
|
|
225
|
+
} as ProcessEvent);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
156
229
|
async stop(): Promise<void> {
|
|
157
230
|
// Persist state
|
|
158
231
|
if (this.ctx) {
|
|
@@ -162,6 +235,8 @@ export class WorkspaceModule implements Module {
|
|
|
162
235
|
state.mounts[name] = {
|
|
163
236
|
lastMaterializedSeq: mount.lastMaterializedSeq,
|
|
164
237
|
lastMaterializedBranchId: mount.lastMaterializedBranchId ?? undefined,
|
|
238
|
+
watcherReadyAt: mount.watcherReadyAt,
|
|
239
|
+
watcherError: mount.watcherError,
|
|
165
240
|
};
|
|
166
241
|
}
|
|
167
242
|
this.ctx.setState(state);
|
|
@@ -330,14 +405,47 @@ export class WorkspaceModule implements Module {
|
|
|
330
405
|
}
|
|
331
406
|
}
|
|
332
407
|
|
|
333
|
-
async onProcess(
|
|
334
|
-
|
|
408
|
+
async onProcess(event: ProcessEvent, _state: ProcessState): Promise<EventResponse> {
|
|
409
|
+
// Wake agents on filesystem events when the originating mount opted in.
|
|
410
|
+
// The EventGate still has final say — gate policies can further filter by
|
|
411
|
+
// mount name, path glob, or op type without requiring a recipe change.
|
|
412
|
+
if (!(WORKSPACE_FS_EVENT_TYPES as readonly string[]).includes(event.type)) {
|
|
413
|
+
return {};
|
|
414
|
+
}
|
|
415
|
+
const mountName = (event as { mount?: string }).mount;
|
|
416
|
+
if (!mountName) return {};
|
|
417
|
+
const mount = this.mounts.get(mountName);
|
|
418
|
+
if (!mount) return {};
|
|
419
|
+
|
|
420
|
+
const flag = mount.config.wakeOnChange;
|
|
421
|
+
if (!flag) return {};
|
|
422
|
+
|
|
423
|
+
const op = event.type.slice('workspace:'.length) as WorkspaceFsOp;
|
|
424
|
+
const opAllowed = flag === true || (Array.isArray(flag) && flag.includes(op));
|
|
425
|
+
if (!opAllowed) return {};
|
|
426
|
+
|
|
427
|
+
return { requestInference: true };
|
|
335
428
|
}
|
|
336
429
|
|
|
337
430
|
// ==========================================================================
|
|
338
431
|
// Path Resolution
|
|
339
432
|
// ==========================================================================
|
|
340
433
|
|
|
434
|
+
/**
|
|
435
|
+
* Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
|
|
436
|
+
* absolute filesystem path. Returns null if the mount is unknown or the
|
|
437
|
+
* resolved path escapes the mount root (path-traversal guard). Public API
|
|
438
|
+
* for peer modules that need to read workspace files directly.
|
|
439
|
+
*/
|
|
440
|
+
resolveAbsolutePath(mountPrefixedPath: string): string | null {
|
|
441
|
+
try {
|
|
442
|
+
const { mount, relativePath } = this.parsePath(mountPrefixedPath);
|
|
443
|
+
return resolve(mount.config.path, relativePath);
|
|
444
|
+
} catch {
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
341
449
|
/**
|
|
342
450
|
* Parse a mount-prefixed path into (mountName, relativePath).
|
|
343
451
|
*/
|
|
@@ -366,6 +474,58 @@ export class WorkspaceModule implements Module {
|
|
|
366
474
|
return this.store;
|
|
367
475
|
}
|
|
368
476
|
|
|
477
|
+
/**
|
|
478
|
+
* Persist a single write/edit/delete to disk when the mount opts in via
|
|
479
|
+
* `autoMaterialize`. Required for cross-agent pipelines — another agent's
|
|
480
|
+
* chokidar watcher on the same directory only sees real filesystem events.
|
|
481
|
+
* The local watcher's `suppress()` absorbs the echo so we don't self-wake.
|
|
482
|
+
*
|
|
483
|
+
* Returns an error string on failure so callers can surface it in the tool
|
|
484
|
+
* result. The autoMaterialize contract is specifically "disk is the source
|
|
485
|
+
* of truth for downstream agents", so a silent disk-write failure with a
|
|
486
|
+
* Chronicle commit would defeat the whole purpose. No-op cases (flag off,
|
|
487
|
+
* read-only mount) return null (success).
|
|
488
|
+
*/
|
|
489
|
+
private async autoMaterialize(
|
|
490
|
+
mount: MountState,
|
|
491
|
+
relativePath: string,
|
|
492
|
+
op: 'write' | 'delete',
|
|
493
|
+
content?: Buffer,
|
|
494
|
+
): Promise<string | null> {
|
|
495
|
+
if (!mount.config.autoMaterialize) return null;
|
|
496
|
+
if (mount.config.mode === 'read-only') return null;
|
|
497
|
+
|
|
498
|
+
const absolutePath = join(mount.config.path, relativePath);
|
|
499
|
+
const watcher = this.watchers.get(mount.config.name);
|
|
500
|
+
watcher?.suppress(relativePath);
|
|
501
|
+
|
|
502
|
+
try {
|
|
503
|
+
if (op === 'write' && content) {
|
|
504
|
+
await mkdir(dirname(absolutePath), { recursive: true });
|
|
505
|
+
await writeFile(absolutePath, content);
|
|
506
|
+
mount.materializedHashes.set(relativePath, hashContent(content));
|
|
507
|
+
} else if (op === 'delete') {
|
|
508
|
+
try {
|
|
509
|
+
await unlink(absolutePath);
|
|
510
|
+
} catch (err) {
|
|
511
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
|
|
512
|
+
}
|
|
513
|
+
mount.materializedHashes.delete(relativePath);
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
} catch (err) {
|
|
517
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
518
|
+
this.ctx?.pushEvent({
|
|
519
|
+
type: 'workspace:materialize-failed',
|
|
520
|
+
mount: mount.config.name,
|
|
521
|
+
path: relativePath,
|
|
522
|
+
op,
|
|
523
|
+
error: msg,
|
|
524
|
+
} as ProcessEvent);
|
|
525
|
+
return msg;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
369
529
|
// ==========================================================================
|
|
370
530
|
// Lazy Sync
|
|
371
531
|
// ==========================================================================
|
|
@@ -456,12 +616,21 @@ export class WorkspaceModule implements Module {
|
|
|
456
616
|
return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
|
|
457
617
|
}
|
|
458
618
|
|
|
459
|
-
const
|
|
619
|
+
const buffer = Buffer.from(input.content, 'utf-8');
|
|
620
|
+
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
460
621
|
store.treeSet(mount.treeStateId, relativePath, {
|
|
461
622
|
blobHash,
|
|
462
623
|
size: Buffer.byteLength(input.content),
|
|
463
624
|
mode: 0o644,
|
|
464
625
|
});
|
|
626
|
+
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', buffer);
|
|
627
|
+
if (materializeError) {
|
|
628
|
+
return {
|
|
629
|
+
success: false,
|
|
630
|
+
error: `Wrote to Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
|
|
631
|
+
isError: true,
|
|
632
|
+
};
|
|
633
|
+
}
|
|
465
634
|
|
|
466
635
|
return {
|
|
467
636
|
success: true,
|
|
@@ -513,12 +682,21 @@ export class WorkspaceModule implements Module {
|
|
|
513
682
|
? content.replaceAll(input.oldString, input.newString)
|
|
514
683
|
: content.replace(input.oldString, input.newString);
|
|
515
684
|
|
|
516
|
-
const
|
|
685
|
+
const newBuffer = Buffer.from(content, 'utf-8');
|
|
686
|
+
const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
|
|
517
687
|
store.treeSet(mount.treeStateId, relativePath, {
|
|
518
688
|
blobHash: newBlobHash,
|
|
519
689
|
size: Buffer.byteLength(content),
|
|
520
690
|
mode: entry.mode,
|
|
521
691
|
});
|
|
692
|
+
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', newBuffer);
|
|
693
|
+
if (materializeError) {
|
|
694
|
+
return {
|
|
695
|
+
success: false,
|
|
696
|
+
error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
|
|
697
|
+
isError: true,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
522
700
|
|
|
523
701
|
return {
|
|
524
702
|
success: true,
|
|
@@ -542,6 +720,14 @@ export class WorkspaceModule implements Module {
|
|
|
542
720
|
}
|
|
543
721
|
|
|
544
722
|
store.treeRemove(mount.treeStateId, relativePath);
|
|
723
|
+
const materializeError = await this.autoMaterialize(mount, relativePath, 'delete');
|
|
724
|
+
if (materializeError) {
|
|
725
|
+
return {
|
|
726
|
+
success: false,
|
|
727
|
+
error: `Removed from Chronicle but failed to unlink "${input.path}" from disk: ${materializeError}. Downstream agents will still see the stale file until manual cleanup.`,
|
|
728
|
+
isError: true,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
545
731
|
|
|
546
732
|
return { success: true, data: { path: input.path, deleted: true } };
|
|
547
733
|
}
|
|
@@ -869,20 +1055,12 @@ export class WorkspaceModule implements Module {
|
|
|
869
1055
|
mount.initialSyncDone = true;
|
|
870
1056
|
|
|
871
1057
|
if (result.synced.length > 0 || result.conflicts.length > 0) {
|
|
872
|
-
allResults.push({
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
paths: result.synced.map(p => `${name}/${p}`),
|
|
879
|
-
mount: name,
|
|
880
|
-
conflicts: result.conflicts.length > 0
|
|
881
|
-
? result.conflicts.map(c => `${name}/${c.path}`)
|
|
882
|
-
: undefined,
|
|
883
|
-
};
|
|
884
|
-
this.ctx.pushEvent(event as ProcessEvent);
|
|
885
|
-
}
|
|
1058
|
+
allResults.push({
|
|
1059
|
+
mount: name,
|
|
1060
|
+
synced: result.synced.map(s => s.path),
|
|
1061
|
+
conflicts: result.conflicts,
|
|
1062
|
+
});
|
|
1063
|
+
this.emitFsEvents(name, result.synced, result.conflicts);
|
|
886
1064
|
}
|
|
887
1065
|
}
|
|
888
1066
|
|
|
@@ -902,23 +1080,76 @@ export class WorkspaceModule implements Module {
|
|
|
902
1080
|
|
|
903
1081
|
/**
|
|
904
1082
|
* Handle filesystem changes detected by watcher (watch: 'always' mode).
|
|
1083
|
+
*
|
|
1084
|
+
* The watcher carries the op per path. For created/modified we call
|
|
1085
|
+
* syncFromFs to pull content into the tree; for deleted we strip the tree
|
|
1086
|
+
* entry directly (the file is gone, nothing to read). We emit one event per
|
|
1087
|
+
* op type.
|
|
905
1088
|
*/
|
|
906
|
-
private async handleFsChanges(mountName: string,
|
|
1089
|
+
private async handleFsChanges(mountName: string, changes: FsChange[]): Promise<void> {
|
|
907
1090
|
const store = this.store;
|
|
908
1091
|
const mount = this.mounts.get(mountName);
|
|
909
1092
|
if (!store || !mount) return;
|
|
910
1093
|
|
|
911
|
-
const
|
|
1094
|
+
const touchedPaths = changes.filter(c => c.op !== 'deleted').map(c => c.path);
|
|
1095
|
+
const deletedPaths = changes.filter(c => c.op === 'deleted').map(c => c.path);
|
|
1096
|
+
|
|
1097
|
+
const syncResult = touchedPaths.length > 0
|
|
1098
|
+
? await syncFromFs(store, mount, touchedPaths)
|
|
1099
|
+
: { synced: [], conflicts: [], skipped: [] };
|
|
1100
|
+
|
|
1101
|
+
// Strip deleted entries from the tree so downstream consumers see a
|
|
1102
|
+
// coherent state. syncFromFs would also do this if we passed the paths in,
|
|
1103
|
+
// but the op from the watcher is authoritative — skip the access() probe.
|
|
1104
|
+
for (const p of deletedPaths) {
|
|
1105
|
+
const existing = store.treeGet(mount.treeStateId, p);
|
|
1106
|
+
if (existing) {
|
|
1107
|
+
store.treeRemove(mount.treeStateId, p);
|
|
1108
|
+
syncResult.synced.push({ path: p, op: 'deleted' });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
this.emitFsEvents(mountName, syncResult.synced, syncResult.conflicts);
|
|
1113
|
+
}
|
|
912
1114
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
1115
|
+
/**
|
|
1116
|
+
* Group synced paths by op and push one ProcessEvent per non-empty op.
|
|
1117
|
+
*/
|
|
1118
|
+
private emitFsEvents(
|
|
1119
|
+
mountName: string,
|
|
1120
|
+
synced: Array<{ path: string; op: WorkspaceFsOp }>,
|
|
1121
|
+
conflicts: ConflictInfo[],
|
|
1122
|
+
): void {
|
|
1123
|
+
if (!this.ctx || synced.length === 0) return;
|
|
1124
|
+
|
|
1125
|
+
const byOp = new Map<WorkspaceFsOp, string[]>();
|
|
1126
|
+
for (const { path, op } of synced) {
|
|
1127
|
+
const list = byOp.get(op) ?? [];
|
|
1128
|
+
list.push(`${mountName}/${path}`);
|
|
1129
|
+
byOp.set(op, list);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Conflicts by definition require a prior tree entry (sync detected that
|
|
1133
|
+
// the agent-side version diverged from the filesystem baseline), which
|
|
1134
|
+
// means the path is being re-synced as a modification. Attach conflicts
|
|
1135
|
+
// only to the workspace:modified event, intersected with its paths — a
|
|
1136
|
+
// created/deleted batch can't carry a meaningful conflict.
|
|
1137
|
+
const conflictsByPath = new Map<string, ConflictInfo>();
|
|
1138
|
+
for (const c of conflicts) conflictsByPath.set(c.path, c);
|
|
1139
|
+
|
|
1140
|
+
for (const [op, paths] of byOp) {
|
|
1141
|
+
const type = opToEventType(op);
|
|
1142
|
+
let eventConflicts: string[] | undefined;
|
|
1143
|
+
if (op === 'modified' && conflictsByPath.size > 0) {
|
|
1144
|
+
const matched = paths.filter(p => conflictsByPath.has(p.slice(mountName.length + 1)));
|
|
1145
|
+
if (matched.length > 0) eventConflicts = matched;
|
|
1146
|
+
}
|
|
1147
|
+
const event = {
|
|
1148
|
+
type,
|
|
1149
|
+
paths,
|
|
917
1150
|
mount: mountName,
|
|
918
|
-
conflicts:
|
|
919
|
-
|
|
920
|
-
: undefined,
|
|
921
|
-
};
|
|
1151
|
+
...(eventConflicts ? { conflicts: eventConflicts } : {}),
|
|
1152
|
+
} as WorkspaceCreatedEvent | WorkspaceModifiedEvent | WorkspaceDeletedEvent;
|
|
922
1153
|
this.ctx.pushEvent(event as ProcessEvent);
|
|
923
1154
|
}
|
|
924
1155
|
}
|
|
@@ -56,9 +56,16 @@ export interface ConflictInfo {
|
|
|
56
56
|
agentBlobHash: string;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export type SyncOp = 'created' | 'modified' | 'deleted';
|
|
60
|
+
|
|
61
|
+
export interface SyncedPath {
|
|
62
|
+
path: string;
|
|
63
|
+
op: SyncOp;
|
|
64
|
+
}
|
|
65
|
+
|
|
59
66
|
export interface SyncResult {
|
|
60
|
-
/** Paths that were synced */
|
|
61
|
-
synced:
|
|
67
|
+
/** Paths that were synced, tagged with the op detected against tree state */
|
|
68
|
+
synced: SyncedPath[];
|
|
62
69
|
/** Paths that conflicted (both agent and user modified) — filesystem wins */
|
|
63
70
|
conflicts: ConflictInfo[];
|
|
64
71
|
/** Paths that were skipped (binary, too large, etc.) */
|
|
@@ -97,7 +104,7 @@ export async function syncFromFs(
|
|
|
97
104
|
const existing = store.treeGet(mount.treeStateId, relativePath);
|
|
98
105
|
if (existing) {
|
|
99
106
|
store.treeRemove(mount.treeStateId, relativePath);
|
|
100
|
-
result.synced.push(relativePath);
|
|
107
|
+
result.synced.push({ path: relativePath, op: 'deleted' });
|
|
101
108
|
}
|
|
102
109
|
continue;
|
|
103
110
|
}
|
|
@@ -151,7 +158,7 @@ export async function syncFromFs(
|
|
|
151
158
|
}
|
|
152
159
|
|
|
153
160
|
store.treeSet(mount.treeStateId, relativePath, entry);
|
|
154
|
-
result.synced.push(relativePath);
|
|
161
|
+
result.synced.push({ path: relativePath, op: existing ? 'modified' : 'created' });
|
|
155
162
|
} catch {
|
|
156
163
|
result.skipped.push(relativePath);
|
|
157
164
|
}
|
|
@@ -36,6 +36,26 @@ export interface MountConfig {
|
|
|
36
36
|
followSymlinks?: boolean;
|
|
37
37
|
/** Maximum file size in bytes (default: 5MB) */
|
|
38
38
|
maxFileSize?: number;
|
|
39
|
+
/**
|
|
40
|
+
* If set, filesystem changes on this mount request inference.
|
|
41
|
+
* - `true` — wake on any op (created | modified | deleted)
|
|
42
|
+
* - array — wake only for the listed ops
|
|
43
|
+
*
|
|
44
|
+
* Self-writes through the module's own tools are already suppressed by the
|
|
45
|
+
* watcher, so a mount shared between agents only wakes on external writes.
|
|
46
|
+
* The EventGate still has final say — use gate policies for path-glob
|
|
47
|
+
* filtering or to disable wake temporarily via hot-reload.
|
|
48
|
+
*/
|
|
49
|
+
wakeOnChange?: boolean | Array<'created' | 'modified' | 'deleted'>;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* If true, writes/edits/deletes via the module's tools materialize to the
|
|
53
|
+
* filesystem immediately (not just into Chronicle tree state). Required for
|
|
54
|
+
* any mount shared across agents as a communication channel — without it,
|
|
55
|
+
* another agent's watcher on the same directory sees nothing. The watcher
|
|
56
|
+
* suppresses its own echoes, so this doesn't cause self-wake loops.
|
|
57
|
+
*/
|
|
58
|
+
autoMaterialize?: boolean;
|
|
39
59
|
}
|
|
40
60
|
|
|
41
61
|
/**
|
|
@@ -74,6 +94,15 @@ export interface MountState {
|
|
|
74
94
|
lastMaterializedBranchId: string | null;
|
|
75
95
|
/** Per-file hash at time of last materialization — baseline for conflict detection */
|
|
76
96
|
materializedHashes: Map<string, string>;
|
|
97
|
+
/**
|
|
98
|
+
* Wall-clock time chokidar emitted `ready` for this mount, or null if the
|
|
99
|
+
* watcher hasn't finished its initial scan. null after session start =
|
|
100
|
+
* watcher attach silently failed; a set value with empty tree = attached
|
|
101
|
+
* to an empty directory.
|
|
102
|
+
*/
|
|
103
|
+
watcherReadyAt: number | null;
|
|
104
|
+
/** Most recent chokidar error for this mount, if any. */
|
|
105
|
+
watcherError: string | null;
|
|
77
106
|
}
|
|
78
107
|
|
|
79
108
|
/**
|
|
@@ -84,6 +113,8 @@ export interface WorkspaceModuleState {
|
|
|
84
113
|
mounts: Record<string, {
|
|
85
114
|
lastMaterializedSeq: number;
|
|
86
115
|
lastMaterializedBranchId?: string;
|
|
116
|
+
watcherReadyAt?: number | null;
|
|
117
|
+
watcherError?: string | null;
|
|
87
118
|
}>;
|
|
88
119
|
/** Branch ID considered "active" for materialization */
|
|
89
120
|
activeBranchId?: string;
|
|
@@ -175,14 +206,36 @@ export interface SyncInput {
|
|
|
175
206
|
// Events
|
|
176
207
|
// ============================================================================
|
|
177
208
|
|
|
178
|
-
export
|
|
179
|
-
|
|
209
|
+
export type WorkspaceFsOp = 'created' | 'modified' | 'deleted';
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Fired when files in a mount are created. `paths` are mount-prefixed
|
|
213
|
+
* (e.g. "tickets/2026-04-20-foo.md"). Conflicts are reported when Chronicle
|
|
214
|
+
* sync detected divergence between the filesystem and the tree state.
|
|
215
|
+
*/
|
|
216
|
+
export interface WorkspaceCreatedEvent {
|
|
217
|
+
type: 'workspace:created';
|
|
180
218
|
paths: string[];
|
|
181
219
|
mount: string;
|
|
182
220
|
conflicts?: string[];
|
|
183
221
|
[key: string]: unknown;
|
|
184
222
|
}
|
|
185
223
|
|
|
224
|
+
export interface WorkspaceModifiedEvent {
|
|
225
|
+
type: 'workspace:modified';
|
|
226
|
+
paths: string[];
|
|
227
|
+
mount: string;
|
|
228
|
+
conflicts?: string[];
|
|
229
|
+
[key: string]: unknown;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export interface WorkspaceDeletedEvent {
|
|
233
|
+
type: 'workspace:deleted';
|
|
234
|
+
paths: string[];
|
|
235
|
+
mount: string;
|
|
236
|
+
[key: string]: unknown;
|
|
237
|
+
}
|
|
238
|
+
|
|
186
239
|
export interface WorkspaceMountedEvent {
|
|
187
240
|
type: 'workspace:mounted';
|
|
188
241
|
mount: string;
|
|
@@ -197,6 +250,20 @@ export interface WorkspaceUnmountedEvent {
|
|
|
197
250
|
}
|
|
198
251
|
|
|
199
252
|
export type WorkspaceEvent =
|
|
200
|
-
|
|
|
253
|
+
| WorkspaceCreatedEvent
|
|
254
|
+
| WorkspaceModifiedEvent
|
|
255
|
+
| WorkspaceDeletedEvent
|
|
201
256
|
| WorkspaceMountedEvent
|
|
202
257
|
| WorkspaceUnmountedEvent;
|
|
258
|
+
|
|
259
|
+
export const WORKSPACE_FS_EVENT_TYPES = [
|
|
260
|
+
'workspace:created',
|
|
261
|
+
'workspace:modified',
|
|
262
|
+
'workspace:deleted',
|
|
263
|
+
] as const;
|
|
264
|
+
|
|
265
|
+
export type WorkspaceFsEventType = typeof WORKSPACE_FS_EVENT_TYPES[number];
|
|
266
|
+
|
|
267
|
+
export function opToEventType(op: WorkspaceFsOp): WorkspaceFsEventType {
|
|
268
|
+
return `workspace:${op}` as WorkspaceFsEventType;
|
|
269
|
+
}
|