@animalabs/agent-framework 0.21.0 → 0.22.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/README.md +43 -0
- package/dist/src/agent.d.ts +17 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +102 -42
- package/dist/src/agent.js.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +22 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +64 -9
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/runtime-py.d.ts +4 -0
- package/dist/src/code-execution/runtime-py.d.ts.map +1 -1
- package/dist/src/code-execution/runtime-py.js +215 -38
- package/dist/src/code-execution/runtime-py.js.map +1 -1
- package/dist/src/framework.d.ts +250 -39
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1602 -371
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +21 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +40 -7
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +5 -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.map +1 -1
- package/dist/src/mcpl/channel-registry.js +26 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +28 -1
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +17 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/feature-set-manager.d.ts +11 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +31 -7
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +1 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +27 -6
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +85 -22
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +32 -4
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +46 -13
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +5 -3
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +41 -11
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +4 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +45 -8
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/server-registry.d.ts +5 -2
- package/dist/src/mcpl/server-registry.d.ts.map +1 -1
- package/dist/src/mcpl/server-registry.js +25 -7
- package/dist/src/mcpl/server-registry.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +52 -2
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +47 -1
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-pattern-check.d.ts +51 -0
- package/dist/src/mcpl/tool-pattern-check.d.ts.map +1 -0
- package/dist/src/mcpl/tool-pattern-check.js +79 -0
- package/dist/src/mcpl/tool-pattern-check.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +15 -5
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/mcpl/visible-content.d.ts +39 -0
- package/dist/src/mcpl/visible-content.d.ts.map +1 -0
- package/dist/src/mcpl/visible-content.js +54 -0
- package/dist/src/mcpl/visible-content.js.map +1 -0
- package/dist/src/module-registry.d.ts +81 -3
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +257 -16
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/branch-intent.d.ts +72 -0
- package/dist/src/modules/workspace/branch-intent.d.ts.map +1 -0
- package/dist/src/modules/workspace/branch-intent.js +83 -0
- package/dist/src/modules/workspace/branch-intent.js.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts +167 -0
- package/dist/src/modules/workspace/disk-agreement.d.ts.map +1 -0
- package/dist/src/modules/workspace/disk-agreement.js +259 -0
- package/dist/src/modules/workspace/disk-agreement.js.map +1 -0
- package/dist/src/modules/workspace/effects.d.ts +84 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -0
- package/dist/src/modules/workspace/effects.js +368 -0
- package/dist/src/modules/workspace/effects.js.map +1 -0
- package/dist/src/modules/workspace/index.d.ts +155 -17
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1059 -424
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +278 -0
- package/dist/src/modules/workspace/observe.d.ts.map +1 -0
- package/dist/src/modules/workspace/observe.js +805 -0
- package/dist/src/modules/workspace/observe.js.map +1 -0
- package/dist/src/modules/workspace/reconcile.d.ts +224 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -0
- package/dist/src/modules/workspace/reconcile.js +901 -0
- package/dist/src/modules/workspace/reconcile.js.map +1 -0
- package/dist/src/modules/workspace/sync.d.ts +6 -59
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +6 -257
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +77 -10
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/record-journal.d.ts +127 -0
- package/dist/src/record-journal.d.ts.map +1 -0
- package/dist/src/record-journal.js +202 -0
- package/dist/src/record-journal.js.map +1 -0
- package/dist/src/shutdown.d.ts +13 -0
- package/dist/src/shutdown.d.ts.map +1 -0
- package/dist/src/shutdown.js +46 -0
- package/dist/src/shutdown.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +74 -0
- package/dist/src/silent-heartbeat.d.ts.map +1 -0
- package/dist/src/silent-heartbeat.js +77 -0
- package/dist/src/silent-heartbeat.js.map +1 -0
- package/dist/src/tool-presentation.d.ts +50 -0
- package/dist/src/tool-presentation.d.ts.map +1 -0
- package/dist/src/tool-presentation.js +235 -0
- package/dist/src/tool-presentation.js.map +1 -0
- package/dist/src/tool-result-guard.d.ts +125 -6
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +310 -35
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +13 -0
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +21 -1
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +18 -2
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +4 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +12 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +14 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +73 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/batched-wake-locus.test.d.ts +2 -0
- package/dist/test/batched-wake-locus.test.d.ts.map +1 -0
- package/dist/test/batched-wake-locus.test.js +166 -0
- package/dist/test/batched-wake-locus.test.js.map +1 -0
- package/dist/test/code-execution.test.js +386 -2
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/component-defaults.test.d.ts +2 -0
- package/dist/test/component-defaults.test.d.ts.map +1 -0
- package/dist/test/component-defaults.test.js +54 -0
- package/dist/test/component-defaults.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +57 -1
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.js +3 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -1
- package/dist/test/event-gate.test.js +118 -0
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +977 -0
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/helpers/pytc-stop-rows.d.ts +11 -0
- package/dist/test/helpers/pytc-stop-rows.d.ts.map +1 -0
- package/dist/test/helpers/pytc-stop-rows.js +143 -0
- package/dist/test/helpers/pytc-stop-rows.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +19 -6
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.d.ts +2 -0
- package/dist/test/mcpl-config-diagnostics.test.d.ts.map +1 -0
- package/dist/test/mcpl-config-diagnostics.test.js +449 -0
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -0
- package/dist/test/mcpl-empty-content.test.d.ts +2 -0
- package/dist/test/mcpl-empty-content.test.d.ts.map +1 -0
- package/dist/test/mcpl-empty-content.test.js +368 -0
- package/dist/test/mcpl-empty-content.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +106 -1
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -1
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js +288 -0
- package/dist/test/mcpl-tool-lifecycle-script.test.js.map +1 -0
- package/dist/test/prose-stream-router.test.js +41 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/record-journal.test.d.ts +6 -0
- package/dist/test/record-journal.test.d.ts.map +1 -0
- package/dist/test/record-journal.test.js +269 -0
- package/dist/test/record-journal.test.js.map +1 -0
- package/dist/test/save-recent-image.test.js +102 -0
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/shutdown-errors.test.d.ts +2 -0
- package/dist/test/shutdown-errors.test.d.ts.map +1 -0
- package/dist/test/shutdown-errors.test.js +662 -0
- package/dist/test/shutdown-errors.test.js.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts +2 -0
- package/dist/test/silent-heartbeat-rows.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat-rows.test.js +451 -0
- package/dist/test/silent-heartbeat-rows.test.js.map +1 -0
- package/dist/test/silent-heartbeat.test.js +49 -38
- package/dist/test/silent-heartbeat.test.js.map +1 -1
- package/dist/test/stop-with-work-in-flight.test.d.ts +23 -0
- package/dist/test/stop-with-work-in-flight.test.d.ts.map +1 -0
- package/dist/test/stop-with-work-in-flight.test.js +672 -0
- package/dist/test/stop-with-work-in-flight.test.js.map +1 -0
- package/dist/test/tool-batch-hook.test.d.ts +8 -0
- package/dist/test/tool-batch-hook.test.d.ts.map +1 -0
- package/dist/test/tool-batch-hook.test.js +275 -0
- package/dist/test/tool-batch-hook.test.js.map +1 -0
- package/dist/test/tool-presentation.test.d.ts +2 -0
- package/dist/test/tool-presentation.test.d.ts.map +1 -0
- package/dist/test/tool-presentation.test.js +268 -0
- package/dist/test/tool-presentation.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +2330 -18
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +2 -1
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/turn-trigger-provenance.test.js +35 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -1
- package/dist/test/unconvertible-tool-result.test.d.ts +2 -0
- package/dist/test/unconvertible-tool-result.test.d.ts.map +1 -0
- package/dist/test/unconvertible-tool-result.test.js +358 -0
- package/dist/test/unconvertible-tool-result.test.js.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts +16 -0
- package/dist/test/workspace-materialize-freshness.test.d.ts.map +1 -0
- package/dist/test/workspace-materialize-freshness.test.js +252 -0
- package/dist/test/workspace-materialize-freshness.test.js.map +1 -0
- package/dist/test/workspace-three-way.test.d.ts +12 -0
- package/dist/test/workspace-three-way.test.d.ts.map +1 -0
- package/dist/test/workspace-three-way.test.js +3079 -0
- package/dist/test/workspace-three-way.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +120 -44
- package/src/code-execution/py-runner.ts +66 -9
- package/src/code-execution/runtime-py.ts +215 -38
- package/src/framework.ts +1661 -363
- package/src/gate/event-gate.ts +59 -7
- package/src/index.ts +11 -0
- package/src/mcpl/channel-registry.ts +24 -3
- package/src/mcpl/conversation-router.ts +34 -1
- package/src/mcpl/feature-set-manager.ts +36 -11
- package/src/mcpl/index.ts +2 -0
- package/src/mcpl/prose-stream-router.ts +79 -23
- package/src/mcpl/push-coalescer.ts +62 -13
- package/src/mcpl/push-handler.ts +41 -12
- package/src/mcpl/server-connection.ts +41 -10
- package/src/mcpl/server-registry.ts +24 -7
- package/src/mcpl/tool-lifecycle.ts +77 -2
- package/src/mcpl/tool-pattern-check.ts +106 -0
- package/src/mcpl/types.ts +15 -5
- package/src/mcpl/visible-content.ts +57 -0
- package/src/module-registry.ts +252 -16
- package/src/modules/workspace/branch-intent.ts +123 -0
- package/src/modules/workspace/disk-agreement.ts +325 -0
- package/src/modules/workspace/effects.ts +362 -0
- package/src/modules/workspace/index.ts +1058 -446
- package/src/modules/workspace/observe.ts +839 -0
- package/src/modules/workspace/reconcile.ts +1136 -0
- package/src/modules/workspace/sync.ts +6 -336
- package/src/modules/workspace/types.ts +75 -10
- package/src/record-journal.ts +246 -0
- package/src/shutdown.ts +49 -0
- package/src/silent-heartbeat.ts +141 -0
- package/src/tool-presentation.ts +188 -0
- package/src/tool-result-guard.ts +335 -33
- package/src/tool-result-history.ts +25 -1
- package/src/types/agent.ts +18 -2
- package/src/types/events.ts +4 -0
- package/src/types/framework.ts +12 -0
- package/src/types/module.ts +15 -0
- package/src/types/trace.ts +80 -0
|
@@ -5,11 +5,22 @@
|
|
|
5
5
|
* auto-sync between real filesystem and Chronicle, and manual materialization.
|
|
6
6
|
*/
|
|
7
7
|
import { constants as fsConstants } from 'node:fs';
|
|
8
|
-
import { open, readFile, stat,
|
|
9
|
-
import { join, resolve, relative,
|
|
8
|
+
import { open, readFile, stat, lstat, realpath } from 'node:fs/promises';
|
|
9
|
+
import { join, resolve, relative, sep } from 'node:path';
|
|
10
10
|
import { WORKSPACE_FS_EVENT_TYPES, opToEventType } from './types.js';
|
|
11
11
|
import { MountWatcher } from './watcher.js';
|
|
12
|
-
import {
|
|
12
|
+
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
13
|
+
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
14
|
+
import { BranchIntents } from './branch-intent.js';
|
|
15
|
+
import { filesystemTrustsCtime, locateRoot } from './observe.js';
|
|
16
|
+
import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js';
|
|
17
|
+
import { pushPaths, reconcilePass, settleAdoptionBeforeMutation, } from './reconcile.js';
|
|
18
|
+
/** Default for how long the after-batch scan may hold the agent's next inference. */
|
|
19
|
+
const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
20
|
+
/** Why a call is refused once the module has stopped. */
|
|
21
|
+
const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
|
|
22
|
+
/** The workspace's own deadline must end before the framework's hook bound. */
|
|
23
|
+
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
13
24
|
class WorkspaceImageReadError extends Error {
|
|
14
25
|
code;
|
|
15
26
|
constructor(code, message) {
|
|
@@ -438,9 +449,22 @@ export class WorkspaceModule {
|
|
|
438
449
|
name = 'workspace';
|
|
439
450
|
ctx = null;
|
|
440
451
|
store = null;
|
|
452
|
+
/** Set once stop has drained and saved, cleared by start (running()). */
|
|
453
|
+
stopped = false;
|
|
441
454
|
config;
|
|
442
455
|
mounts = new Map();
|
|
443
456
|
watchers = new Map();
|
|
457
|
+
/** What disk last agreed with (global records); created with the store. */
|
|
458
|
+
agreement = null;
|
|
459
|
+
/** Branch-local intent per mount. */
|
|
460
|
+
intents = new Map();
|
|
461
|
+
/** Each mount's turn: the tail of its serialized passes and writes. */
|
|
462
|
+
mountTurns = new Map();
|
|
463
|
+
/** Whether an over-bound agentActionScanDeadlineMs has been warned about. */
|
|
464
|
+
warnedScanDeadline = false;
|
|
465
|
+
/** Whether a mount root's filesystem maintains ctime, by canonical root. */
|
|
466
|
+
/** Per canonical root: the device of its filesystem when that maintains ctime, else null. */
|
|
467
|
+
ctimeTrust = new Map();
|
|
444
468
|
/** Persisted state decoded in start(), held until mounts exist to apply it
|
|
445
469
|
* to — in the Host ordering start() runs before initStore() creates the
|
|
446
470
|
* mounts, so restoration must be second-callback-safe like watcher
|
|
@@ -482,38 +506,112 @@ export class WorkspaceModule {
|
|
|
482
506
|
*/
|
|
483
507
|
initStore(store) {
|
|
484
508
|
this.store = store;
|
|
485
|
-
// Register tree states for each mount
|
|
509
|
+
// Register tree states for each mount: the workspace tree, and the
|
|
510
|
+
// branch-local intent that qualifies it.
|
|
486
511
|
for (const mount of this.config.mounts) {
|
|
487
512
|
const treeStateId = `workspace/${mount.name}/tree`;
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
513
|
+
const intentTreeStateId = `workspace/${mount.name}/intent`;
|
|
514
|
+
for (const id of [treeStateId, intentTreeStateId]) {
|
|
515
|
+
try {
|
|
516
|
+
store.registerState({
|
|
517
|
+
id,
|
|
518
|
+
strategy: 'tree',
|
|
519
|
+
deltaSnapshotEvery: this.config.deltaSnapshotEvery ?? 50,
|
|
520
|
+
fullSnapshotEvery: this.config.fullSnapshotEvery ?? 10,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
// State already registered (restart scenario)
|
|
525
|
+
}
|
|
498
526
|
}
|
|
499
527
|
const mountState = {
|
|
500
528
|
config: mount,
|
|
501
529
|
treeStateId,
|
|
530
|
+
intentTreeStateId,
|
|
502
531
|
lastMaterializedSeq: 0,
|
|
503
532
|
suppressedPaths: new Set(),
|
|
504
533
|
initialSyncDone: false,
|
|
505
534
|
lastMaterializedBranchId: null,
|
|
506
|
-
materializedHashes: new Map(),
|
|
507
535
|
watcherReadyAt: null,
|
|
508
536
|
watcherError: null,
|
|
509
537
|
};
|
|
510
538
|
this.mounts.set(mount.name, mountState);
|
|
539
|
+
this.intents.set(mount.name, new BranchIntents(store, intentTreeStateId));
|
|
511
540
|
}
|
|
541
|
+
// What disk last agreed with, per mount and path: global records, so a
|
|
542
|
+
// branch switch never rewinds it.
|
|
543
|
+
this.agreement = new DiskAgreement(store);
|
|
544
|
+
this.agreement.open(this.config.mounts.map((m) => ({ name: m.name, root: resolve(m.path) })));
|
|
512
545
|
this.applySavedState();
|
|
513
546
|
this.ensureRunning();
|
|
514
547
|
}
|
|
548
|
+
// ==========================================================================
|
|
549
|
+
// Reconciliation plumbing
|
|
550
|
+
// ==========================================================================
|
|
551
|
+
/**
|
|
552
|
+
* Run `fn` with the mount to itself: observation, decision and effect never
|
|
553
|
+
* interleave with another pass or a tool write on the same mount (a disk
|
|
554
|
+
* observation taken before an autoMaterialize write and applied after it
|
|
555
|
+
* would ingest old bytes over the new entry). Not re-entrant: code holding
|
|
556
|
+
* a mount's turn calls the unlocked helpers.
|
|
557
|
+
*/
|
|
558
|
+
withMount(mount, fn) {
|
|
559
|
+
const name = mount.config.name;
|
|
560
|
+
const previous = this.mountTurns.get(name) ?? Promise.resolve();
|
|
561
|
+
const run = previous.then(fn, fn);
|
|
562
|
+
this.mountTurns.set(name, run.then(() => undefined, () => undefined));
|
|
563
|
+
return run;
|
|
564
|
+
}
|
|
565
|
+
agreementOrThrow() {
|
|
566
|
+
if (!this.agreement)
|
|
567
|
+
throw new Error('Workspace store not initialized');
|
|
568
|
+
return this.agreement;
|
|
569
|
+
}
|
|
570
|
+
/** The view reconciliation needs of a mount, refreshed per pass. */
|
|
571
|
+
async runtime(mount) {
|
|
572
|
+
const root = resolve(mount.config.path);
|
|
573
|
+
const rootReal = await realpath(root).catch(() => root);
|
|
574
|
+
let trustedDev = this.ctimeTrust.get(rootReal);
|
|
575
|
+
if (trustedDev === undefined) {
|
|
576
|
+
const dev = (await stat(rootReal, { bigint: true }).catch(() => null))?.dev;
|
|
577
|
+
trustedDev = dev !== undefined && (await filesystemTrustsCtime(rootReal)) ? dev.toString() : null;
|
|
578
|
+
this.ctimeTrust.set(rootReal, trustedDev);
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
name: mount.config.name,
|
|
582
|
+
view: {
|
|
583
|
+
root,
|
|
584
|
+
rootIdentity: this.agreementOrThrow().rootIdentity(mount.config.name),
|
|
585
|
+
followSymlinks: mount.config.followSymlinks === true,
|
|
586
|
+
maxFileSize: mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE,
|
|
587
|
+
ignore: mount.config.ignore ?? [],
|
|
588
|
+
},
|
|
589
|
+
rootReal,
|
|
590
|
+
ctimeTrustedDev: trustedDev,
|
|
591
|
+
treeStateId: mount.treeStateId,
|
|
592
|
+
intents: this.intents.get(mount.config.name),
|
|
593
|
+
readOnly: mount.config.mode === 'read-only',
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
/** One disk→store pass; the caller holds the mount's turn. Emits its events. */
|
|
597
|
+
async passUnlocked(mount, scope, opts = {}) {
|
|
598
|
+
const result = await reconcilePass(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), scope, opts);
|
|
599
|
+
if (scope.kind === 'dir' && scope.dir === '' && scope.recursive)
|
|
600
|
+
mount.initialSyncDone = true;
|
|
601
|
+
this.emitFsEvents(mount.config.name, result.ops, result.newConflicts);
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
604
|
+
/** One store→disk push; the caller holds the mount's turn. */
|
|
605
|
+
async pushUnlocked(mount, paths, opts = {}) {
|
|
606
|
+
const watcher = this.watchers.get(mount.config.name);
|
|
607
|
+
return pushPaths(this.getStore(), this.agreementOrThrow(), await this.runtime(mount), paths, {
|
|
608
|
+
...opts,
|
|
609
|
+
beforeEffect: (p) => watcher?.suppress(p),
|
|
610
|
+
});
|
|
611
|
+
}
|
|
515
612
|
async start(ctx) {
|
|
516
613
|
this.ctx = ctx;
|
|
614
|
+
this.stopped = false;
|
|
517
615
|
// Decode persisted state if restarting; application waits until mounts
|
|
518
616
|
// exist (see applySavedState) — in the Host ordering they don't yet.
|
|
519
617
|
if (ctx.isRestart) {
|
|
@@ -535,9 +633,18 @@ export class WorkspaceModule {
|
|
|
535
633
|
return;
|
|
536
634
|
for (const [name, meta] of Object.entries(this.savedState.mounts)) {
|
|
537
635
|
const mount = this.mounts.get(name);
|
|
538
|
-
|
|
636
|
+
// A mount materialized this session already (a call between initStore
|
|
637
|
+
// and start) has a pin and watermark newer than anything saved.
|
|
638
|
+
if (mount && mount.lastMaterializedBranchId === null && mount.lastMaterializedSeq === 0) {
|
|
539
639
|
mount.lastMaterializedSeq = meta.lastMaterializedSeq;
|
|
540
640
|
mount.lastMaterializedBranchId = meta.lastMaterializedBranchId ?? null;
|
|
641
|
+
// A store that ran #169's stop-time baselines (#109) saved them here
|
|
642
|
+
// too. They aren't carried over as evidence: #169 recorded no root,
|
|
643
|
+
// so nothing binds them to the directory they describe, and an empty
|
|
644
|
+
// mountpoint at the next start would read as every file deleted.
|
|
645
|
+
// Such a store gains its evidence from its first pass instead: a
|
|
646
|
+
// file disk agrees with is recorded, and anything else is a conflict
|
|
647
|
+
// or unknown provenance, never an adoption.
|
|
541
648
|
// watcherReadyAt intentionally not restored — each session must
|
|
542
649
|
// observe its own watcher attach, otherwise a stale timestamp
|
|
543
650
|
// would hide a new-session attach failure.
|
|
@@ -669,12 +776,10 @@ export class WorkspaceModule {
|
|
|
669
776
|
});
|
|
670
777
|
watcher.start();
|
|
671
778
|
this.watchers.set(name, watcher);
|
|
672
|
-
// Chokidar is started with ignoreInitial:true, so
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
// sessions see the existing catalog; restarts only see what appeared
|
|
677
|
-
// while the session was down.
|
|
779
|
+
// Chokidar is started with ignoreInitial:true, so changes made while
|
|
780
|
+
// the session was down would be invisible. One full pass catches up
|
|
781
|
+
// under the three-way rule: only real differences produce tree
|
|
782
|
+
// changes and events.
|
|
678
783
|
void this.initialScan(name);
|
|
679
784
|
}
|
|
680
785
|
this.ctx.pushEvent({
|
|
@@ -690,9 +795,9 @@ export class WorkspaceModule {
|
|
|
690
795
|
if (!store || !mount)
|
|
691
796
|
return;
|
|
692
797
|
try {
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
this.
|
|
798
|
+
// A full pass under the three-way rule: deletions on disk are confirmed
|
|
799
|
+
// where nothing newer is pending, and nothing unvisited is touched.
|
|
800
|
+
await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true }));
|
|
696
801
|
}
|
|
697
802
|
catch (err) {
|
|
698
803
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -704,6 +809,24 @@ export class WorkspaceModule {
|
|
|
704
809
|
}
|
|
705
810
|
}
|
|
706
811
|
async stop() {
|
|
812
|
+
// No new watcher-driven passes, then let every pass and write finish
|
|
813
|
+
// before the store goes away — including any queued behind the ones in
|
|
814
|
+
// flight (a tool call, a scan continuing past its deadline): drained
|
|
815
|
+
// until no new tail appears.
|
|
816
|
+
for (const watcher of this.watchers.values()) {
|
|
817
|
+
await watcher.stop();
|
|
818
|
+
}
|
|
819
|
+
this.watchers.clear();
|
|
820
|
+
for (;;) {
|
|
821
|
+
const tails = [...this.mountTurns.values()];
|
|
822
|
+
await Promise.all(tails);
|
|
823
|
+
const now = [...this.mountTurns.values()];
|
|
824
|
+
if (now.length === tails.length && now.every((tail, i) => tail === tails[i]))
|
|
825
|
+
break;
|
|
826
|
+
}
|
|
827
|
+
// Nothing is queued now, and nothing more is taken: what follows saves
|
|
828
|
+
// the pins and watermarks as the last work left them.
|
|
829
|
+
this.stopped = true;
|
|
707
830
|
// Persist state
|
|
708
831
|
if (this.ctx) {
|
|
709
832
|
const activeBranchId = this.store ? this.store.currentBranch().id : undefined;
|
|
@@ -718,11 +841,6 @@ export class WorkspaceModule {
|
|
|
718
841
|
}
|
|
719
842
|
this.ctx.setState(state);
|
|
720
843
|
}
|
|
721
|
-
// Stop watchers
|
|
722
|
-
for (const watcher of this.watchers.values()) {
|
|
723
|
-
await watcher.stop();
|
|
724
|
-
}
|
|
725
|
-
this.watchers.clear();
|
|
726
844
|
this.ctx = null;
|
|
727
845
|
}
|
|
728
846
|
/** Mount names + modes, for peer callers (framework spill/journal paths)
|
|
@@ -796,7 +914,9 @@ export class WorkspaceModule {
|
|
|
796
914
|
},
|
|
797
915
|
{
|
|
798
916
|
name: 'delete',
|
|
799
|
-
description: 'Delete a file from the workspace.'
|
|
917
|
+
description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
|
|
918
|
+
+ 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
|
|
919
|
+
+ 'or a sync of the path restores it.',
|
|
800
920
|
inputSchema: {
|
|
801
921
|
type: 'object',
|
|
802
922
|
properties: {
|
|
@@ -807,7 +927,14 @@ export class WorkspaceModule {
|
|
|
807
927
|
},
|
|
808
928
|
{
|
|
809
929
|
name: 'ls',
|
|
810
|
-
description: 'List directory
|
|
930
|
+
description: 'List a directory, checked against disk first. Each file has a state: synced; '
|
|
931
|
+
+ 'workspace-draft (the workspace has changes disk lacks); workspace-deleted (deleted in the workspace, '
|
|
932
|
+
+ 'still on disk); not-in-branch (on disk, absent from this branch\'s workspace); disk-only (binary or over '
|
|
933
|
+
+ 'the size limit, never stored — shown with size and image type); conflict, with its kind (disk and the '
|
|
934
|
+
+ 'workspace both changed; disk holds a different copy and nothing records which is newer; or a write was '
|
|
935
|
+
+ 'interrupted) — sync the path to take disk, or materialize that path with force to take the workspace; '
|
|
936
|
+
+ 'disk-missing-provenance-unknown; unverified (disk could not be checked). `incomplete` names regions '
|
|
937
|
+
+ 'the listing could not see.',
|
|
811
938
|
inputSchema: {
|
|
812
939
|
type: 'object',
|
|
813
940
|
properties: {
|
|
@@ -818,7 +945,7 @@ export class WorkspaceModule {
|
|
|
818
945
|
},
|
|
819
946
|
{
|
|
820
947
|
name: 'glob',
|
|
821
|
-
description: 'Find files matching a glob pattern
|
|
948
|
+
description: 'Find files matching a glob pattern, checked against disk first; each match carries its state, as ls describes.',
|
|
822
949
|
inputSchema: {
|
|
823
950
|
type: 'object',
|
|
824
951
|
properties: {
|
|
@@ -830,7 +957,11 @@ export class WorkspaceModule {
|
|
|
830
957
|
},
|
|
831
958
|
{
|
|
832
959
|
name: 'grep',
|
|
833
|
-
description: 'Search file
|
|
960
|
+
description: 'Search stored file text with a regex pattern, checked against disk first. Each result says '
|
|
961
|
+
+ 'which version matched: the workspace\'s (with its state); the disk version kept with a conflict '
|
|
962
|
+
+ '(conflicting-disk); or, once disk has changed since the conflict was recorded, that recorded version '
|
|
963
|
+
+ '(recorded-disk). Disk versions it could not search are listed as skipped: disk-only files (binary or '
|
|
964
|
+
+ 'over the size limit), and a conflict\'s disk version that is binary, oversize, or newer than the one recorded.',
|
|
834
965
|
inputSchema: {
|
|
835
966
|
type: 'object',
|
|
836
967
|
properties: {
|
|
@@ -855,24 +986,45 @@ export class WorkspaceModule {
|
|
|
855
986
|
},
|
|
856
987
|
{
|
|
857
988
|
name: 'materialize',
|
|
858
|
-
description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk.'
|
|
989
|
+
description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
|
|
990
|
+
+ 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk.',
|
|
859
991
|
inputSchema: {
|
|
860
992
|
type: 'object',
|
|
861
993
|
properties: {
|
|
862
|
-
path: { type: 'string', description: 'Specific path to materialize (optional — defaults to
|
|
994
|
+
path: { type: 'string', description: 'Specific path to materialize (optional — defaults to what disk still owes: workspace edits, conflicts, workspace deletions, and files never checked against disk)' },
|
|
863
995
|
mount: { type: 'string', description: 'Specific mount (optional)' },
|
|
864
|
-
force: { type: 'boolean', description: '
|
|
996
|
+
force: { type: 'boolean', description: 'Overwrite files whose disk copy changed since it last agreed with the workspace (another writer, or a conflict), and materialize even if the current branch has diverged from the branch last written to disk (default false). Without it, divergent files are skipped and listed. A file the workspace has not changed is left as disk has it unless you name its path.' },
|
|
997
|
+
applyDeletions: { type: 'boolean', description: 'Also delete from disk the files deleted in the workspace whose disk copy is one the workspace holds (default false). With force, also those whose disk copy changed since.' },
|
|
865
998
|
},
|
|
866
999
|
},
|
|
867
1000
|
},
|
|
868
1001
|
{
|
|
869
1002
|
name: 'sync',
|
|
870
|
-
description: '
|
|
1003
|
+
description: 'Bring disk changes into the workspace. Without a path, every mount (or the given one) is '
|
|
1004
|
+
+ 'rechecked in full and nothing pending in the workspace is discarded. With a path (file or directory), '
|
|
1005
|
+
+ 'the workspace takes disk\'s state there explicitly: it restores a workspace-deleted file, resolves '
|
|
1006
|
+
+ 'conflicts toward disk, replaces a workspace draft, and removes a workspace file disk does not have. '
|
|
1007
|
+
+ 'Each workspace change it gives up is listed under `discarded`, with the state it had and, as `op`, '
|
|
1008
|
+
+ 'what that did to the workspace copy (absent when it changed none: a deletion or conflict given up for '
|
|
1009
|
+
+ 'a disk copy the workspace can\'t hold). '
|
|
1010
|
+
+ 'A mount whose root is not the directory disk last agreed with (an unmounted drive, a replaced '
|
|
1011
|
+
+ 'directory) is unavailable and listed under `incomplete`. Reconnect the drive; acceptRoot is for a '
|
|
1012
|
+
+ 'directory replaced on purpose, or a drive back under a new device number, never for one still '
|
|
1013
|
+
+ 'unplugged, whose empty mountpoint would become the root.',
|
|
871
1014
|
inputSchema: {
|
|
872
1015
|
type: 'object',
|
|
873
1016
|
properties: {
|
|
874
|
-
path: { type: 'string', description: 'Specific path to
|
|
1017
|
+
path: { type: 'string', description: 'Specific path to take from disk (optional — defaults to a full recheck)' },
|
|
875
1018
|
mount: { type: 'string', description: 'Specific mount (optional)' },
|
|
1019
|
+
acceptRoot: {
|
|
1020
|
+
type: 'boolean',
|
|
1021
|
+
description: 'Take each mount\'s root as it is now when it is not the directory disk last agreed with: '
|
|
1022
|
+
+ 'a directory replaced on purpose, or a drive that came back under a new device number (default false). '
|
|
1023
|
+
+ 'What was recorded about the old one is set aside, so every file is compared afresh: a file disk '
|
|
1024
|
+
+ 'and the workspace agree on agrees again, a differing one is a conflict, and a workspace file the '
|
|
1025
|
+
+ 'root lacks stays in the workspace. Not with path. Leave it off while a drive is merely unplugged: '
|
|
1026
|
+
+ 'its empty mountpoint would become the root.',
|
|
1027
|
+
},
|
|
876
1028
|
},
|
|
877
1029
|
},
|
|
878
1030
|
},
|
|
@@ -881,9 +1033,52 @@ export class WorkspaceModule {
|
|
|
881
1033
|
// ==========================================================================
|
|
882
1034
|
// Tool Dispatch
|
|
883
1035
|
// ==========================================================================
|
|
1036
|
+
generatedTextFiles = new Map();
|
|
1037
|
+
/** Compare the same normalized physical path for registration and tool access. */
|
|
1038
|
+
generatedFileKey(path) {
|
|
1039
|
+
const slash = path.indexOf('/');
|
|
1040
|
+
const mountName = slash < 0 ? path : path.slice(0, slash);
|
|
1041
|
+
const mount = this.config.mounts.find(m => m.name === mountName);
|
|
1042
|
+
if (!mount)
|
|
1043
|
+
throw new Error(`Unknown mount: "${mountName}"`);
|
|
1044
|
+
const resolved = resolve(mount.path, slash < 0 ? '' : path.slice(slash + 1));
|
|
1045
|
+
if (!isContainedPath(mount.path, resolved))
|
|
1046
|
+
throw new Error(`Path traversal detected: "${path}"`);
|
|
1047
|
+
return resolved;
|
|
1048
|
+
}
|
|
1049
|
+
findGeneratedTextFile(path) {
|
|
1050
|
+
if (!this.generatedTextFiles.size)
|
|
1051
|
+
return undefined;
|
|
1052
|
+
try {
|
|
1053
|
+
return this.generatedTextFiles.get(this.generatedFileKey(path));
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
// Not an alias of a valid generated file. Let the selected tool return
|
|
1057
|
+
// its established validation error (including image-specific errors).
|
|
1058
|
+
return undefined;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
/** Generated files bypass stored blobs so every read reflects current definitions. */
|
|
1062
|
+
registerGeneratedTextFile(path, read, agentName) {
|
|
1063
|
+
const [mount, ...parts] = path.split('/');
|
|
1064
|
+
if (!this.config.mounts.some(m => m.name === mount) || !parts.length
|
|
1065
|
+
|| parts.some(p => !p || p === '.' || p === '..') || this.generatedTextFiles.has(this.generatedFileKey(path)))
|
|
1066
|
+
throw new Error(`Duplicate/invalid generated path: ${path}`);
|
|
1067
|
+
this.generatedTextFiles.set(this.generatedFileKey(path), { read, agentName });
|
|
1068
|
+
}
|
|
884
1069
|
async handleToolCall(call) {
|
|
1070
|
+
if (!this.running())
|
|
1071
|
+
return { success: false, isError: true, error: NOT_RUNNING };
|
|
885
1072
|
try {
|
|
886
1073
|
const input = call.input;
|
|
1074
|
+
const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
1075
|
+
if (generated) {
|
|
1076
|
+
if (call.callerAgentName !== generated.agentName)
|
|
1077
|
+
return { success: false, isError: true, error: 'Generated file belongs to another agent' };
|
|
1078
|
+
if (call.name !== 'read')
|
|
1079
|
+
return { success: false, isError: true, error: 'Generated file is read-only; edit the presentation configuration instead' };
|
|
1080
|
+
return await this.handleRead(input, generated.read());
|
|
1081
|
+
}
|
|
887
1082
|
switch (call.name) {
|
|
888
1083
|
case 'read': return await this.handleRead(input);
|
|
889
1084
|
case 'read_image': return await this.handleReadImage(input);
|
|
@@ -1002,8 +1197,25 @@ export class WorkspaceModule {
|
|
|
1002
1197
|
* mount, through the same Chronicle-tree + auto-materialize path as the
|
|
1003
1198
|
* `write` tool. Public API for the framework's synthesized `save_image`
|
|
1004
1199
|
* tool and other peer callers that hold bytes rather than text.
|
|
1200
|
+
*
|
|
1201
|
+
* `branch`, when given, ties the write to that branch: the store's current
|
|
1202
|
+
* branch is checked where the tree entry is set, nothing yielding between
|
|
1203
|
+
* the check and the entry, so the file's tree entry lands on that branch or
|
|
1204
|
+
* the write is refused, with `data: { branchLeft: true }`. A mount has one
|
|
1205
|
+
* directory on disk, so with autoMaterialize the file's copy there is
|
|
1206
|
+
* written like any other write's. The tool-result guard writes a
|
|
1207
|
+
* withheld original this way, for the branch its stub is on
|
|
1208
|
+
* (agent-framework #277).
|
|
1209
|
+
*
|
|
1210
|
+
* A write whose tree entry is set but that then fails returns success
|
|
1211
|
+
* false with `data: { inChronicle: true }`: its copy on disk was refused, or
|
|
1212
|
+
* something after the entry (recording its intent, the event, the push)
|
|
1213
|
+
* threw. The workspace's tools read the file from the tree, so it is there
|
|
1214
|
+
* for them, on the branch that was current when the entry was set.
|
|
1005
1215
|
*/
|
|
1006
|
-
async writeBinary(mountPrefixedPath, data, mimeType) {
|
|
1216
|
+
async writeBinary(mountPrefixedPath, data, mimeType, options = {}) {
|
|
1217
|
+
if (!this.running())
|
|
1218
|
+
return { success: false, isError: true, error: NOT_RUNNING };
|
|
1007
1219
|
let mount;
|
|
1008
1220
|
let relativePath;
|
|
1009
1221
|
try {
|
|
@@ -1024,24 +1236,37 @@ export class WorkspaceModule {
|
|
|
1024
1236
|
return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
|
|
1025
1237
|
}
|
|
1026
1238
|
const store = this.getStore();
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1239
|
+
return this.withMount(mount, async () => {
|
|
1240
|
+
await this.settleBeforeMutation(mount, relativePath);
|
|
1241
|
+
// Checked inside the mount's turn, after anything the write waited on:
|
|
1242
|
+
// commitToolChange sets the tree entry before it first yields.
|
|
1243
|
+
if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
|
|
1244
|
+
return { success: false, error: 'the workspace had left the branch this file is for', isError: true, data: { branchLeft: true } };
|
|
1245
|
+
}
|
|
1246
|
+
const blobHash = store.storeBlob(data, mimeType);
|
|
1247
|
+
let committed = false;
|
|
1248
|
+
let failure;
|
|
1249
|
+
try {
|
|
1250
|
+
const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: data.byteLength }, () => { committed = true; });
|
|
1251
|
+
failure = materializeError === null ? null
|
|
1252
|
+
: `Wrote to Chronicle but failed to materialize "${mountPrefixedPath}" to disk: ${materializeError}.`;
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
if (!committed)
|
|
1256
|
+
throw error;
|
|
1257
|
+
failure = `Stored "${mountPrefixedPath}" in Chronicle, but the rest of the write failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
1258
|
+
}
|
|
1259
|
+
if (failure !== null) {
|
|
1260
|
+
// The file is in the Chronicle tree, where the workspace's tools read
|
|
1261
|
+
// it. `inChronicle` lets a caller that needs the file readable tell
|
|
1262
|
+
// this from a write that never happened (agent-framework #281).
|
|
1263
|
+
return { success: false, error: failure, isError: true, data: { inChronicle: true } };
|
|
1264
|
+
}
|
|
1035
1265
|
return {
|
|
1036
|
-
success:
|
|
1037
|
-
|
|
1038
|
-
isError: true,
|
|
1266
|
+
success: true,
|
|
1267
|
+
data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
|
|
1039
1268
|
};
|
|
1040
|
-
}
|
|
1041
|
-
return {
|
|
1042
|
-
success: true,
|
|
1043
|
-
data: { path: mountPrefixedPath, size: data.byteLength, mimeType },
|
|
1044
|
-
};
|
|
1269
|
+
});
|
|
1045
1270
|
}
|
|
1046
1271
|
/**
|
|
1047
1272
|
* Read a file's raw bytes from a mount (through the Chronicle tree, synced
|
|
@@ -1049,6 +1274,8 @@ export class WorkspaceModule {
|
|
|
1049
1274
|
* `read_image` tool and other peer callers that need binary content.
|
|
1050
1275
|
*/
|
|
1051
1276
|
async readBinary(mountPrefixedPath) {
|
|
1277
|
+
if (!this.running())
|
|
1278
|
+
return { error: NOT_RUNNING };
|
|
1052
1279
|
let mount;
|
|
1053
1280
|
let relativePath;
|
|
1054
1281
|
try {
|
|
@@ -1058,14 +1285,16 @@ export class WorkspaceModule {
|
|
|
1058
1285
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
1059
1286
|
}
|
|
1060
1287
|
const store = this.getStore();
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1288
|
+
return this.withMount(mount, async () => {
|
|
1289
|
+
await this.ensureSynced(mount, relativePath);
|
|
1290
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
1291
|
+
if (!entry)
|
|
1292
|
+
return { error: `File not found: ${mountPrefixedPath}` };
|
|
1293
|
+
const blob = store.getBlob(entry.blobHash);
|
|
1294
|
+
if (!blob)
|
|
1295
|
+
return { error: `Blob not found for: ${mountPrefixedPath}` };
|
|
1296
|
+
return { data: blob };
|
|
1297
|
+
});
|
|
1069
1298
|
}
|
|
1070
1299
|
/**
|
|
1071
1300
|
* Read a file's raw bytes straight from the mount's filesystem, bypassing
|
|
@@ -1129,6 +1358,17 @@ export class WorkspaceModule {
|
|
|
1129
1358
|
}
|
|
1130
1359
|
return { mount, relativePath };
|
|
1131
1360
|
}
|
|
1361
|
+
/**
|
|
1362
|
+
* Whether the module takes work: stop hasn't finished. Work admitted while
|
|
1363
|
+
* stop drains still runs; once stop has saved the pins and watermarks,
|
|
1364
|
+
* nothing more changes them, so it takes nothing until it starts again. A
|
|
1365
|
+
* call before start is taken, as it always was (a generated file's read
|
|
1366
|
+
* needs no store at all): in the host's order, start runs first, and
|
|
1367
|
+
* initStore applies the saved pins.
|
|
1368
|
+
*/
|
|
1369
|
+
running() {
|
|
1370
|
+
return !this.stopped;
|
|
1371
|
+
}
|
|
1132
1372
|
getStore() {
|
|
1133
1373
|
if (!this.store)
|
|
1134
1374
|
throw new Error('WorkspaceModule: store not initialized. Call initStore() first.');
|
|
@@ -1421,96 +1661,122 @@ export class WorkspaceModule {
|
|
|
1421
1661
|
}
|
|
1422
1662
|
}
|
|
1423
1663
|
/**
|
|
1424
|
-
*
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1664
|
+
* Commit a tool's write or delete to the workspace tree, with the branch
|
|
1665
|
+
* intent it implies (store origin for a path disk never agreed with; a
|
|
1666
|
+
* tombstone for a deletion), and push it to disk when the mount opts in via
|
|
1667
|
+
* `autoMaterialize` — required for cross-agent pipelines, since another
|
|
1668
|
+
* agent's watcher on the same directory only sees real filesystem events.
|
|
1669
|
+
* The push follows materialize's rule: a disk copy that changed since it
|
|
1670
|
+
* last agreed is not overwritten or unlinked, and the refusal says why.
|
|
1428
1671
|
*
|
|
1429
|
-
* Returns
|
|
1430
|
-
*
|
|
1431
|
-
* of truth for downstream agents
|
|
1432
|
-
*
|
|
1433
|
-
* read-only mount) return null (success).
|
|
1672
|
+
* Returns the reason when the change reached the workspace but not disk, so
|
|
1673
|
+
* callers surface it in the tool result: the autoMaterialize contract is
|
|
1674
|
+
* that disk is the source of truth for downstream agents. The caller holds
|
|
1675
|
+
* the mount's turn and has settled the path first (settleBeforeMutation).
|
|
1434
1676
|
*/
|
|
1435
|
-
async
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
const
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
}
|
|
1459
|
-
return null;
|
|
1677
|
+
async commitToolChange(mount, relativePath, change, onCommitted) {
|
|
1678
|
+
const store = this.getStore();
|
|
1679
|
+
const intents = this.intents.get(mount.config.name);
|
|
1680
|
+
// The mutation's precondition, enforced here, synchronously with the
|
|
1681
|
+
// commit: whatever settleBeforeMutation's pass could or couldn't settle.
|
|
1682
|
+
settleAdoptionBeforeMutation(store, this.agreementOrThrow(), { name: mount.config.name, treeStateId: mount.treeStateId }, relativePath);
|
|
1683
|
+
const known = this.agreementOrThrow().get(mount.config.name, relativePath) !== undefined;
|
|
1684
|
+
if (change.kind === 'set') {
|
|
1685
|
+
store.treeSet(mount.treeStateId, relativePath, { blobHash: change.blobHash, size: change.size, mode: change.mode ?? 0o644 });
|
|
1686
|
+
// A later intent update or disk push can throw; the tree entry is
|
|
1687
|
+
// already set, on the branch current now. If the active branch changes
|
|
1688
|
+
// during the push, that branch's tree has the file and the new one may
|
|
1689
|
+
// not.
|
|
1690
|
+
onCommitted?.();
|
|
1691
|
+
intents.update(relativePath, (cur) => {
|
|
1692
|
+
const next = { ...cur };
|
|
1693
|
+
delete next.tombstone;
|
|
1694
|
+
// Disk has never agreed with this path: a missing disk copy is a draft
|
|
1695
|
+
// not yet materialized, not a deletion.
|
|
1696
|
+
if (!known)
|
|
1697
|
+
next.origin = 'store';
|
|
1698
|
+
return next;
|
|
1699
|
+
});
|
|
1460
1700
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
op,
|
|
1468
|
-
error: msg,
|
|
1701
|
+
else {
|
|
1702
|
+
store.treeRemove(mount.treeStateId, relativePath);
|
|
1703
|
+
intents.update(relativePath, (cur) => {
|
|
1704
|
+
const next = { ...cur, tombstone: { hash: change.previousHash } };
|
|
1705
|
+
delete next.origin;
|
|
1706
|
+
return next;
|
|
1469
1707
|
});
|
|
1470
|
-
return msg;
|
|
1471
1708
|
}
|
|
1709
|
+
if (!mount.config.autoMaterialize || mount.config.mode === 'read-only')
|
|
1710
|
+
return null;
|
|
1711
|
+
const pushed = await this.pushUnlocked(mount, [relativePath], {
|
|
1712
|
+
applyDeletions: change.kind === 'remove',
|
|
1713
|
+
branchId: store.currentBranch().id,
|
|
1714
|
+
});
|
|
1715
|
+
const refused = pushed.skipped.find((s) => s.path === relativePath);
|
|
1716
|
+
if (!refused)
|
|
1717
|
+
return null;
|
|
1718
|
+
this.ctx?.pushEvent({
|
|
1719
|
+
type: 'workspace:materialize-failed',
|
|
1720
|
+
mount: mount.config.name,
|
|
1721
|
+
path: relativePath,
|
|
1722
|
+
op: change.kind === 'set' ? 'write' : 'delete',
|
|
1723
|
+
error: refused.reason,
|
|
1724
|
+
});
|
|
1725
|
+
return refused.reason;
|
|
1472
1726
|
}
|
|
1473
1727
|
// ==========================================================================
|
|
1474
1728
|
// Lazy Sync
|
|
1475
1729
|
// ==========================================================================
|
|
1476
1730
|
/**
|
|
1477
|
-
*
|
|
1731
|
+
* Before a tool reads or changes a path's workspace entry, a pending intent
|
|
1732
|
+
* on that path is settled by a pass over it, so the tool works from what
|
|
1733
|
+
* disk now holds (an edit sees a disk change the settled adoption took in).
|
|
1734
|
+
* The pass may not settle it (a branch that keeps changing, an unreadable
|
|
1735
|
+
* disk copy); the precondition that matters is enforced again,
|
|
1736
|
+
* synchronously, where the change commits (commitToolChange). The caller
|
|
1737
|
+
* holds the mount's turn.
|
|
1738
|
+
*/
|
|
1739
|
+
async settleBeforeMutation(mount, relativePath) {
|
|
1740
|
+
if (this.agreementOrThrow().get(mount.config.name, relativePath)?.kind !== 'pending')
|
|
1741
|
+
return;
|
|
1742
|
+
await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Before a read: a path the workspace doesn't hold is observed under the
|
|
1746
|
+
* three-way rule, which ingests a new disk file but never brings back a
|
|
1747
|
+
* workspace deletion or a file absent from this branch. The caller holds
|
|
1748
|
+
* the mount's turn.
|
|
1478
1749
|
*/
|
|
1479
1750
|
async ensureSynced(mount, relativePath) {
|
|
1480
1751
|
const store = this.getStore();
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
return;
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
return
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
mode: 0o644,
|
|
1504
|
-
});
|
|
1505
|
-
}
|
|
1506
|
-
catch {
|
|
1507
|
-
// File doesn't exist on disk — that's fine
|
|
1752
|
+
if (store.treeGet(mount.treeStateId, relativePath))
|
|
1753
|
+
return undefined;
|
|
1754
|
+
const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] });
|
|
1755
|
+
return pass.reports.get(relativePath);
|
|
1756
|
+
}
|
|
1757
|
+
/** Why the workspace holds no entry at a path, when disk explains it. */
|
|
1758
|
+
notInWorkspace(path, report) {
|
|
1759
|
+
switch (report?.state) {
|
|
1760
|
+
case 'workspace-deleted':
|
|
1761
|
+
return `${path} was deleted in the workspace; disk still has a copy. Sync this path to restore it.`;
|
|
1762
|
+
case 'not-in-branch':
|
|
1763
|
+
return `${path} is not in this branch's workspace; disk has a copy. Sync this path to add it.`;
|
|
1764
|
+
case 'disk-only':
|
|
1765
|
+
return `${path} is on disk but not stored in the workspace (binary or over the size limit)` +
|
|
1766
|
+
(report.mimeType?.startsWith('image/') ? '; read it with read_image.' : '.');
|
|
1767
|
+
case 'conflict':
|
|
1768
|
+
return `${path} is in conflict: the workspace deleted it and disk changed it since. ` +
|
|
1769
|
+
'Sync this path to take the disk version.';
|
|
1770
|
+
case 'unverified':
|
|
1771
|
+
return `${path} is not in the workspace, and disk could not be checked (${report.note ?? 'not observed'}).`;
|
|
1772
|
+
default:
|
|
1773
|
+
return `File not found: ${path}`;
|
|
1508
1774
|
}
|
|
1509
1775
|
}
|
|
1510
1776
|
// ==========================================================================
|
|
1511
1777
|
// Tool Handlers
|
|
1512
1778
|
// ==========================================================================
|
|
1513
|
-
async handleRead(input) {
|
|
1779
|
+
async handleRead(input, generatedContent) {
|
|
1514
1780
|
const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
|
|
1515
1781
|
const offsetChars = input.offsetChars ?? 0;
|
|
1516
1782
|
const limitChars = input.limitChars ?? 2000;
|
|
@@ -1524,18 +1790,24 @@ export class WorkspaceModule {
|
|
|
1524
1790
|
return { success: false, isError: true, error: 'offsetChars must be a non-negative safe integer and limitChars a positive safe integer.' };
|
|
1525
1791
|
}
|
|
1526
1792
|
}
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1793
|
+
let content = generatedContent;
|
|
1794
|
+
if (content === undefined) {
|
|
1795
|
+
const { mount, relativePath } = this.parsePath(input.path);
|
|
1796
|
+
const store = this.getStore();
|
|
1797
|
+
const found = await this.withMount(mount, async () => {
|
|
1798
|
+
const report = await this.ensureSynced(mount, relativePath);
|
|
1799
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
1800
|
+
if (!entry)
|
|
1801
|
+
return { error: this.notInWorkspace(input.path, report) };
|
|
1802
|
+
const blob = store.getBlob(entry.blobHash);
|
|
1803
|
+
if (!blob)
|
|
1804
|
+
return { error: `Blob not found for: ${input.path}` };
|
|
1805
|
+
return { text: blob.toString('utf-8') };
|
|
1806
|
+
});
|
|
1807
|
+
if ('error' in found)
|
|
1808
|
+
return { success: false, error: found.error, isError: true };
|
|
1809
|
+
content = found.text;
|
|
1537
1810
|
}
|
|
1538
|
-
const content = blob.toString('utf-8');
|
|
1539
1811
|
if (characterPaging) {
|
|
1540
1812
|
const start = Math.min(offsetChars, content.length);
|
|
1541
1813
|
const splitsPair = (at) => at > 0 && at < content.length
|
|
@@ -1670,13 +1942,11 @@ export class WorkspaceModule {
|
|
|
1670
1942
|
return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
|
|
1671
1943
|
}
|
|
1672
1944
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
1673
|
-
const
|
|
1674
|
-
|
|
1675
|
-
blobHash,
|
|
1676
|
-
size:
|
|
1677
|
-
mode: 0o644,
|
|
1945
|
+
const materializeError = await this.withMount(mount, async () => {
|
|
1946
|
+
await this.settleBeforeMutation(mount, relativePath);
|
|
1947
|
+
const blobHash = store.storeBlob(buffer, 'text/plain');
|
|
1948
|
+
return this.commitToolChange(mount, relativePath, { kind: 'set', blobHash, size: buffer.byteLength });
|
|
1678
1949
|
});
|
|
1679
|
-
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', buffer);
|
|
1680
1950
|
if (materializeError) {
|
|
1681
1951
|
return {
|
|
1682
1952
|
success: false,
|
|
@@ -1699,55 +1969,58 @@ export class WorkspaceModule {
|
|
|
1699
1969
|
return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
|
|
1700
1970
|
}
|
|
1701
1971
|
const store = this.getStore();
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
if (
|
|
1716
|
-
|
|
1972
|
+
return this.withMount(mount, async () => {
|
|
1973
|
+
const report = await this.ensureSynced(mount, relativePath);
|
|
1974
|
+
await this.settleBeforeMutation(mount, relativePath); // before the edit reads the entry
|
|
1975
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
1976
|
+
if (!entry) {
|
|
1977
|
+
return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
|
|
1978
|
+
}
|
|
1979
|
+
const blob = store.getBlob(entry.blobHash);
|
|
1980
|
+
if (!blob) {
|
|
1981
|
+
return { success: false, error: `Blob not found for: ${input.path}`, isError: true };
|
|
1982
|
+
}
|
|
1983
|
+
let content = blob.toString('utf-8');
|
|
1984
|
+
// Validate uniqueness
|
|
1985
|
+
if (!input.replaceAll) {
|
|
1986
|
+
const count = content.split(input.oldString).length - 1;
|
|
1987
|
+
if (count === 0) {
|
|
1988
|
+
return { success: false, error: `String not found in ${input.path}`, isError: true };
|
|
1989
|
+
}
|
|
1990
|
+
if (count > 1) {
|
|
1991
|
+
return {
|
|
1992
|
+
success: false,
|
|
1993
|
+
error: `String found ${count} times in ${input.path}. Use replaceAll: true or provide more context.`,
|
|
1994
|
+
isError: true,
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1717
1997
|
}
|
|
1718
|
-
|
|
1998
|
+
content = input.replaceAll
|
|
1999
|
+
? content.replaceAll(input.oldString, input.newString)
|
|
2000
|
+
: content.replace(input.oldString, input.newString);
|
|
2001
|
+
const newBuffer = Buffer.from(content, 'utf-8');
|
|
2002
|
+
const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
|
|
2003
|
+
const materializeError = await this.commitToolChange(mount, relativePath, {
|
|
2004
|
+
kind: 'set',
|
|
2005
|
+
blobHash: newBlobHash,
|
|
2006
|
+
size: newBuffer.byteLength,
|
|
2007
|
+
mode: entry.mode,
|
|
2008
|
+
});
|
|
2009
|
+
if (materializeError) {
|
|
1719
2010
|
return {
|
|
1720
2011
|
success: false,
|
|
1721
|
-
error: `
|
|
2012
|
+
error: `Edited Chronicle but failed to materialize "${input.path}" to disk: ${materializeError}. Downstream agents will not see this change until the next materialize call.`,
|
|
1722
2013
|
isError: true,
|
|
1723
2014
|
};
|
|
1724
2015
|
}
|
|
1725
|
-
}
|
|
1726
|
-
content = input.replaceAll
|
|
1727
|
-
? content.replaceAll(input.oldString, input.newString)
|
|
1728
|
-
: content.replace(input.oldString, input.newString);
|
|
1729
|
-
const newBuffer = Buffer.from(content, 'utf-8');
|
|
1730
|
-
const newBlobHash = store.storeBlob(newBuffer, 'text/plain');
|
|
1731
|
-
store.treeSet(mount.treeStateId, relativePath, {
|
|
1732
|
-
blobHash: newBlobHash,
|
|
1733
|
-
size: Buffer.byteLength(content),
|
|
1734
|
-
mode: entry.mode,
|
|
1735
|
-
});
|
|
1736
|
-
const materializeError = await this.autoMaterialize(mount, relativePath, 'write', newBuffer);
|
|
1737
|
-
if (materializeError) {
|
|
1738
2016
|
return {
|
|
1739
|
-
success:
|
|
1740
|
-
|
|
1741
|
-
|
|
2017
|
+
success: true,
|
|
2018
|
+
data: {
|
|
2019
|
+
path: input.path,
|
|
2020
|
+
size: newBuffer.byteLength,
|
|
2021
|
+
},
|
|
1742
2022
|
};
|
|
1743
|
-
}
|
|
1744
|
-
return {
|
|
1745
|
-
success: true,
|
|
1746
|
-
data: {
|
|
1747
|
-
path: input.path,
|
|
1748
|
-
size: Buffer.byteLength(content),
|
|
1749
|
-
},
|
|
1750
|
-
};
|
|
2023
|
+
});
|
|
1751
2024
|
}
|
|
1752
2025
|
async handleDelete(input) {
|
|
1753
2026
|
const { mount, relativePath } = this.parsePath(input.path);
|
|
@@ -1755,20 +2028,24 @@ export class WorkspaceModule {
|
|
|
1755
2028
|
return { success: false, error: `Mount "${mount.config.name}" is read-only`, isError: true };
|
|
1756
2029
|
}
|
|
1757
2030
|
const store = this.getStore();
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
2031
|
+
return this.withMount(mount, async () => {
|
|
2032
|
+
const report = await this.ensureSynced(mount, relativePath);
|
|
2033
|
+
await this.settleBeforeMutation(mount, relativePath);
|
|
2034
|
+
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
2035
|
+
if (!entry) {
|
|
2036
|
+
return { success: false, error: this.notInWorkspace(input.path, report), isError: true };
|
|
2037
|
+
}
|
|
2038
|
+
const materializeError = await this.commitToolChange(mount, relativePath, { kind: 'remove', previousHash: entry.blobHash });
|
|
2039
|
+
if (materializeError) {
|
|
2040
|
+
return {
|
|
2041
|
+
success: false,
|
|
2042
|
+
error: `Removed from Chronicle but did not unlink "${input.path}" from disk: ${materializeError}. ` +
|
|
2043
|
+
'The disk copy stays (ls shows its state) until materialize with applyDeletions removes it.',
|
|
2044
|
+
isError: true,
|
|
2045
|
+
};
|
|
2046
|
+
}
|
|
2047
|
+
return { success: true, data: { path: input.path, deleted: true } };
|
|
2048
|
+
});
|
|
1772
2049
|
}
|
|
1773
2050
|
async handleLs(input) {
|
|
1774
2051
|
const store = this.getStore();
|
|
@@ -1782,74 +2059,67 @@ export class WorkspaceModule {
|
|
|
1782
2059
|
return { success: true, data: { mounts } };
|
|
1783
2060
|
}
|
|
1784
2061
|
const { mount, relativePath } = this.parsePath(input.path);
|
|
1785
|
-
|
|
1786
|
-
//
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
2062
|
+
const recursive = input.recursive === true;
|
|
2063
|
+
// Every listing checks the paths it shows against disk first, under the
|
|
2064
|
+
// three-way rule, whatever the mount's watch mode.
|
|
2065
|
+
return this.withMount(mount, async () => {
|
|
2066
|
+
const pass = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive });
|
|
2067
|
+
const reports = [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
2068
|
+
const incomplete = pass.incomplete.length > 0 ? { incomplete: pass.incomplete } : {};
|
|
2069
|
+
if (recursive) {
|
|
2070
|
+
return {
|
|
2071
|
+
success: true,
|
|
2072
|
+
data: {
|
|
2073
|
+
path: input.path,
|
|
2074
|
+
entries: reports.map((r) => ({ path: r.path, ...stateView(r) })),
|
|
2075
|
+
count: reports.length,
|
|
2076
|
+
...incomplete,
|
|
2077
|
+
},
|
|
2078
|
+
};
|
|
2079
|
+
}
|
|
2080
|
+
// Non-recursive: the scope's files, and the directories beneath it that
|
|
2081
|
+
// disk or the workspace has.
|
|
2082
|
+
const prefix = relativePath ? relativePath + '/' : '';
|
|
2083
|
+
const dirNames = new Set(pass.dirs.map((d) => d.slice(prefix.length)));
|
|
2084
|
+
for (const entry of store.treeList(mount.treeStateId, prefix || undefined)) {
|
|
2085
|
+
const slash = entry.path.indexOf('/', prefix.length);
|
|
2086
|
+
if (slash >= 0)
|
|
2087
|
+
dirNames.add(entry.path.slice(prefix.length, slash));
|
|
2088
|
+
}
|
|
2089
|
+
const children = [
|
|
2090
|
+
...[...dirNames].sort().map((name) => ({ name, type: 'directory' })),
|
|
2091
|
+
...reports.map((r) => ({ name: r.path.slice(prefix.length), type: 'file', ...stateView(r) })),
|
|
2092
|
+
];
|
|
1794
2093
|
return {
|
|
1795
2094
|
success: true,
|
|
1796
2095
|
data: {
|
|
1797
2096
|
path: input.path,
|
|
1798
|
-
entries:
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
})),
|
|
1802
|
-
count: entries.length,
|
|
2097
|
+
entries: children,
|
|
2098
|
+
count: children.length,
|
|
2099
|
+
...incomplete,
|
|
1803
2100
|
},
|
|
1804
2101
|
};
|
|
1805
|
-
}
|
|
1806
|
-
// Non-recursive: deduplicate to show immediate children only
|
|
1807
|
-
const seen = new Set();
|
|
1808
|
-
const children = [];
|
|
1809
|
-
for (const entry of entries) {
|
|
1810
|
-
const rest = entry.path.slice(prefix.length);
|
|
1811
|
-
const slashIdx = rest.indexOf('/');
|
|
1812
|
-
if (slashIdx >= 0) {
|
|
1813
|
-
const dirName = rest.slice(0, slashIdx);
|
|
1814
|
-
if (!seen.has(dirName)) {
|
|
1815
|
-
seen.add(dirName);
|
|
1816
|
-
children.push({ name: dirName, type: 'directory' });
|
|
1817
|
-
}
|
|
1818
|
-
}
|
|
1819
|
-
else {
|
|
1820
|
-
children.push({ name: rest, type: 'file' });
|
|
1821
|
-
}
|
|
1822
|
-
}
|
|
1823
|
-
return {
|
|
1824
|
-
success: true,
|
|
1825
|
-
data: {
|
|
1826
|
-
path: input.path,
|
|
1827
|
-
entries: children,
|
|
1828
|
-
count: children.length,
|
|
1829
|
-
},
|
|
1830
|
-
};
|
|
2102
|
+
});
|
|
1831
2103
|
}
|
|
1832
2104
|
async handleGlob(input) {
|
|
1833
|
-
const store = this.getStore();
|
|
1834
2105
|
const regex = globToRegex(input.pattern);
|
|
1835
2106
|
const matches = [];
|
|
2107
|
+
const incomplete = [];
|
|
1836
2108
|
// Search across mounts
|
|
1837
2109
|
const mountsToSearch = input.path
|
|
1838
2110
|
? [this.parsePath(input.path)]
|
|
1839
2111
|
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
|
|
1840
2112
|
for (const { mount, relativePath } of mountsToSearch) {
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
}
|
|
1845
|
-
const prefix = relativePath ? relativePath + '/' : undefined;
|
|
1846
|
-
const entries = store.treeList(mount.treeStateId, prefix);
|
|
1847
|
-
for (const entry of entries) {
|
|
1848
|
-
const testPath = relativePath ? entry.path.slice(relativePath.length + 1) : entry.path;
|
|
2113
|
+
const pass = await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }));
|
|
2114
|
+
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
2115
|
+
const testPath = relativePath ? report.path.slice(relativePath.length + 1) : report.path;
|
|
1849
2116
|
if (regex.test(testPath)) {
|
|
1850
|
-
matches.push(`${mount.config.name}/${
|
|
2117
|
+
matches.push({ path: `${mount.config.name}/${report.path}`, ...stateView(report) });
|
|
1851
2118
|
}
|
|
1852
2119
|
}
|
|
2120
|
+
for (const region of pass.incomplete) {
|
|
2121
|
+
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
2122
|
+
}
|
|
1853
2123
|
}
|
|
1854
2124
|
return {
|
|
1855
2125
|
success: true,
|
|
@@ -1857,6 +2127,7 @@ export class WorkspaceModule {
|
|
|
1857
2127
|
pattern: input.pattern,
|
|
1858
2128
|
matches,
|
|
1859
2129
|
count: matches.length,
|
|
2130
|
+
...(incomplete.length > 0 ? { incomplete } : {}),
|
|
1860
2131
|
},
|
|
1861
2132
|
};
|
|
1862
2133
|
}
|
|
@@ -1876,56 +2147,107 @@ export class WorkspaceModule {
|
|
|
1876
2147
|
? [this.parsePath(input.path)]
|
|
1877
2148
|
: [...this.mounts.values()].map(m => ({ mount: m, relativePath: '' }));
|
|
1878
2149
|
const results = [];
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
: null;
|
|
1892
|
-
if (fileNode && fileNode.blobHash) {
|
|
1893
|
-
entries = [{ path: relativePath, blobHash: fileNode.blobHash }];
|
|
1894
|
-
}
|
|
1895
|
-
else {
|
|
1896
|
-
const prefix = relativePath ? relativePath + '/' : undefined;
|
|
1897
|
-
entries = store.treeList(mount.treeStateId, prefix);
|
|
1898
|
-
}
|
|
1899
|
-
for (const entry of entries) {
|
|
1900
|
-
if (fileGlob && !fileGlob.test(entry.path))
|
|
1901
|
-
continue;
|
|
1902
|
-
const blob = store.getBlob(entry.blobHash);
|
|
1903
|
-
if (!blob)
|
|
1904
|
-
continue;
|
|
1905
|
-
const content = blob.toString('utf-8');
|
|
1906
|
-
const lines = content.split('\n');
|
|
1907
|
-
const fileMatches = [];
|
|
1908
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1909
|
-
if (regex.test(lines[i])) {
|
|
1910
|
-
const match = {
|
|
1911
|
-
line: i + 1,
|
|
1912
|
-
text: lines[i],
|
|
1913
|
-
};
|
|
1914
|
-
if (contextBefore > 0 || contextAfter > 0) {
|
|
1915
|
-
const start = Math.max(0, i - contextBefore);
|
|
1916
|
-
const end = Math.min(lines.length, i + contextAfter + 1);
|
|
1917
|
-
match.context = lines.slice(start, end);
|
|
1918
|
-
}
|
|
1919
|
-
fileMatches.push(match);
|
|
2150
|
+
const skipped = [];
|
|
2151
|
+
const incomplete = [];
|
|
2152
|
+
const search = (content) => {
|
|
2153
|
+
const lines = content.split('\n');
|
|
2154
|
+
const found = [];
|
|
2155
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2156
|
+
if (regex.test(lines[i])) {
|
|
2157
|
+
const match = { line: i + 1, text: lines[i] };
|
|
2158
|
+
if (contextBefore > 0 || contextAfter > 0) {
|
|
2159
|
+
const start = Math.max(0, i - contextBefore);
|
|
2160
|
+
const end = Math.min(lines.length, i + contextAfter + 1);
|
|
2161
|
+
match.context = lines.slice(start, end);
|
|
1920
2162
|
}
|
|
2163
|
+
found.push(match);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
return found;
|
|
2167
|
+
};
|
|
2168
|
+
for (const { mount, relativePath } of mountsToSearch) {
|
|
2169
|
+
await this.withMount(mount, async () => {
|
|
2170
|
+
// A `path` naming a single file greps just that file; otherwise it is a
|
|
2171
|
+
// directory prefix. (A file path once produced the prefix "notes.md/"
|
|
2172
|
+
// and silently matched nothing.)
|
|
2173
|
+
const fileScope = relativePath !== '' && (store.treeGet(mount.treeStateId, relativePath) !== null ||
|
|
2174
|
+
await lstat(join(mount.config.path, relativePath)).then((s) => !s.isDirectory(), () => false));
|
|
2175
|
+
// What follows reads the tree and branch intent to go with the pass's
|
|
2176
|
+
// reports, synchronously. They must be the branch the pass decided
|
|
2177
|
+
// on: one selected in between would label its content with the other
|
|
2178
|
+
// branch's states. So a pass whose branch is no longer selected runs
|
|
2179
|
+
// again, and a branch that keeps changing leaves the scope unsearched.
|
|
2180
|
+
let pass = null;
|
|
2181
|
+
for (let attempt = 0; attempt < 3 && pass === null; attempt++) {
|
|
2182
|
+
const next = await this.passUnlocked(mount, fileScope
|
|
2183
|
+
? { kind: 'paths', paths: [relativePath] }
|
|
2184
|
+
: { kind: 'dir', dir: relativePath, recursive: true });
|
|
2185
|
+
if (next.branchId === undefined || next.branchId === store.currentBranch().id)
|
|
2186
|
+
pass = next;
|
|
1921
2187
|
}
|
|
1922
|
-
if (
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
2188
|
+
if (pass === null) {
|
|
2189
|
+
incomplete.push({
|
|
2190
|
+
path: `${mount.config.name}/${relativePath}`,
|
|
2191
|
+
reason: 'the selected branch kept changing while grep ran; nothing here was searched — grep again',
|
|
1926
2192
|
});
|
|
2193
|
+
return;
|
|
1927
2194
|
}
|
|
1928
|
-
|
|
2195
|
+
for (const region of pass.incomplete) {
|
|
2196
|
+
incomplete.push({ path: `${mount.config.name}/${region.path}`, reason: region.reason });
|
|
2197
|
+
}
|
|
2198
|
+
const intents = this.intents.get(mount.config.name);
|
|
2199
|
+
for (const report of [...pass.reports.values()].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
2200
|
+
if (fileGlob && !fileGlob.test(report.path))
|
|
2201
|
+
continue;
|
|
2202
|
+
const file = `${mount.config.name}/${report.path}`;
|
|
2203
|
+
if (report.state === 'disk-only') {
|
|
2204
|
+
skipped.push({
|
|
2205
|
+
file,
|
|
2206
|
+
reason: 'disk-only: binary or over the size limit, never stored',
|
|
2207
|
+
...(report.size !== undefined ? { size: report.size } : {}),
|
|
2208
|
+
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
2209
|
+
});
|
|
2210
|
+
continue;
|
|
2211
|
+
}
|
|
2212
|
+
// The workspace's version, labelled with the path's state.
|
|
2213
|
+
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
2214
|
+
const blob = entry ? store.getBlob(entry.blobHash) : null;
|
|
2215
|
+
if (blob) {
|
|
2216
|
+
const found = search(blob.toString('utf-8'));
|
|
2217
|
+
if (found.length > 0)
|
|
2218
|
+
results.push({ file, state: report.state, version: 'workspace', matches: found });
|
|
2219
|
+
}
|
|
2220
|
+
else if (entry) {
|
|
2221
|
+
skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
|
|
2222
|
+
}
|
|
2223
|
+
if (report.state !== 'conflict')
|
|
2224
|
+
continue;
|
|
2225
|
+
// A conflict's disk side. The version recorded with the conflict is
|
|
2226
|
+
// disk's only while disk still holds it; once disk has changed
|
|
2227
|
+
// since, it is labelled as recorded, and a file disk holds now
|
|
2228
|
+
// (never stored) is listed as not searched. A binary or oversize
|
|
2229
|
+
// disk version was never stored either.
|
|
2230
|
+
const disk = intents.get(report.path)?.conflict?.disk;
|
|
2231
|
+
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
2232
|
+
const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
|
|
2233
|
+
if (diskBlob) {
|
|
2234
|
+
const found = search(diskBlob.toString('utf-8'));
|
|
2235
|
+
if (found.length > 0)
|
|
2236
|
+
results.push({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', matches: found });
|
|
2237
|
+
}
|
|
2238
|
+
if (disk?.stored && !diskBlob) {
|
|
2239
|
+
skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
|
|
2240
|
+
}
|
|
2241
|
+
if (changed) {
|
|
2242
|
+
if (report.diskNow === 'file') {
|
|
2243
|
+
skipped.push({ file, reason: 'conflict: disk changed since the conflict was recorded; grep searches stored text, so the file disk holds now was not searched' });
|
|
2244
|
+
}
|
|
2245
|
+
}
|
|
2246
|
+
else if (disk && !disk.stored) {
|
|
2247
|
+
skipped.push({ file, reason: 'conflict: the disk version is binary or over the size limit, never stored', size: disk.size });
|
|
2248
|
+
}
|
|
2249
|
+
}
|
|
2250
|
+
});
|
|
1929
2251
|
}
|
|
1930
2252
|
return {
|
|
1931
2253
|
success: true,
|
|
@@ -1933,18 +2255,32 @@ export class WorkspaceModule {
|
|
|
1933
2255
|
pattern: input.pattern,
|
|
1934
2256
|
results,
|
|
1935
2257
|
totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
|
|
2258
|
+
...(skipped.length > 0 ? { skipped } : {}),
|
|
2259
|
+
...(incomplete.length > 0 ? { incomplete } : {}),
|
|
1936
2260
|
},
|
|
1937
2261
|
};
|
|
1938
2262
|
}
|
|
1939
|
-
async handleStatus(
|
|
2263
|
+
async handleStatus(input) {
|
|
1940
2264
|
const store = this.getStore();
|
|
1941
2265
|
const status = {};
|
|
2266
|
+
if (input.mount !== undefined && !this.mounts.has(input.mount)) {
|
|
2267
|
+
return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
|
|
2268
|
+
}
|
|
1942
2269
|
for (const [name, mount] of this.mounts) {
|
|
2270
|
+
if (input.mount !== undefined && name !== input.mount)
|
|
2271
|
+
continue;
|
|
1943
2272
|
const entries = store.treeList(mount.treeStateId);
|
|
1944
2273
|
const currentSeq = store.currentSequence();
|
|
1945
|
-
const
|
|
1946
|
-
|
|
1947
|
-
|
|
2274
|
+
const intents = this.intents.get(name).list();
|
|
2275
|
+
const conflictPaths = intents.filter(([, bi]) => bi.conflict).map(([p]) => p);
|
|
2276
|
+
// A workspace deletion is pending until a push confirms it on disk:
|
|
2277
|
+
// left there without applyDeletions, or unlinked but unconfirmed.
|
|
2278
|
+
const deletionPaths = intents.filter(([, bi]) => bi.tombstone).map(([p]) => p);
|
|
2279
|
+
// Pending is what disk owes by the evidence: every entry it doesn't
|
|
2280
|
+
// show on disk (a refused or failed push stays owed), conflicts and
|
|
2281
|
+
// deletions. An entry from before the evidence isn't counted until a
|
|
2282
|
+
// listing or a materialize checks it: unchecked isn't unpushed.
|
|
2283
|
+
const pending = new Set([...conflictPaths, ...deletionPaths, ...this.entriesByEvidence(mount).owed]);
|
|
1948
2284
|
const currentBranch = store.currentBranch();
|
|
1949
2285
|
status[name] = {
|
|
1950
2286
|
path: mount.config.path,
|
|
@@ -1953,20 +2289,93 @@ export class WorkspaceModule {
|
|
|
1953
2289
|
fileCount: entries.length,
|
|
1954
2290
|
lastMaterializedSeq: mount.lastMaterializedSeq,
|
|
1955
2291
|
currentSeq,
|
|
1956
|
-
pendingChanges:
|
|
2292
|
+
pendingChanges: pending.size,
|
|
2293
|
+
conflicts: conflictPaths.length,
|
|
2294
|
+
pendingWorkspaceDeletions: deletionPaths.length,
|
|
1957
2295
|
initialSyncDone: mount.initialSyncDone,
|
|
1958
2296
|
currentBranch: currentBranch.name,
|
|
1959
2297
|
lastMaterializedBranch: mount.lastMaterializedBranchId,
|
|
1960
2298
|
canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
|
|
2299
|
+
...(mount.lastAgentActionScan ? { lastAgentActionScan: mount.lastAgentActionScan } : {}),
|
|
1961
2300
|
};
|
|
1962
2301
|
}
|
|
1963
2302
|
return { success: true, data: status };
|
|
1964
2303
|
}
|
|
2304
|
+
/**
|
|
2305
|
+
* What a materialize of `mount` should consider: a named file, or every
|
|
2306
|
+
* tracked path beneath a named directory; otherwise what the evidence says
|
|
2307
|
+
* disk still owes, the entries never checked against disk, recorded
|
|
2308
|
+
* conflicts, and workspace deletions still on disk (applied with
|
|
2309
|
+
* `applyDeletions`, listed without it). The watermark plays no part: it is
|
|
2310
|
+
* lost at an unclean restart, and an entry the evidence shows on disk has
|
|
2311
|
+
* nothing to push. If disk changed since, a sync adopts that; a bare
|
|
2312
|
+
* materialize, forced or not, never reverts it (naming the path with force
|
|
2313
|
+
* does). `inPlace` says whether any tree entry was left out for that reason.
|
|
2314
|
+
*/
|
|
2315
|
+
materializeSelection(mount, explicitPath) {
|
|
2316
|
+
const store = this.getStore();
|
|
2317
|
+
const intents = new Map(this.intents.get(mount.config.name).list(explicitPath));
|
|
2318
|
+
const selected = new Set();
|
|
2319
|
+
if (explicitPath !== '') {
|
|
2320
|
+
if (store.treeGet(mount.treeStateId, explicitPath))
|
|
2321
|
+
selected.add(explicitPath);
|
|
2322
|
+
for (const e of store.treeList(mount.treeStateId, explicitPath + '/'))
|
|
2323
|
+
selected.add(e.path);
|
|
2324
|
+
for (const [p, bi] of intents)
|
|
2325
|
+
if (bi.tombstone || bi.conflict)
|
|
2326
|
+
selected.add(p);
|
|
2327
|
+
return { paths: [...selected], inPlace: false };
|
|
2328
|
+
}
|
|
2329
|
+
const { owed, unchecked, agreed } = this.entriesByEvidence(mount);
|
|
2330
|
+
for (const path of [...owed, ...unchecked])
|
|
2331
|
+
selected.add(path);
|
|
2332
|
+
for (const [p, bi] of intents)
|
|
2333
|
+
if (bi.conflict || bi.tombstone)
|
|
2334
|
+
selected.add(p);
|
|
2335
|
+
return { paths: [...selected], inPlace: agreed > 0 };
|
|
2336
|
+
}
|
|
2337
|
+
/**
|
|
2338
|
+
* The workspace entries by what the evidence says of disk. `owed`: P
|
|
2339
|
+
* differs from the entry (a draft, or a push still pending), or there is no
|
|
2340
|
+
* P and the entry came from the workspace (a draft disk never had).
|
|
2341
|
+
* `unchecked`: no P and no workspace origin, an entry from before the
|
|
2342
|
+
* evidence; a push agrees it, writes it where disk lacks it, or refuses it
|
|
2343
|
+
* as a conflict. `agreed`: how many have P = S, so disk holds them. Status
|
|
2344
|
+
* counts the owed as pending; a materialize without a path takes up the
|
|
2345
|
+
* owed and the unchecked.
|
|
2346
|
+
*/
|
|
2347
|
+
entriesByEvidence(mount) {
|
|
2348
|
+
const store = this.getStore();
|
|
2349
|
+
const agreement = this.agreementOrThrow();
|
|
2350
|
+
const name = mount.config.name;
|
|
2351
|
+
const intents = this.intents.get(name);
|
|
2352
|
+
const owed = [];
|
|
2353
|
+
const unchecked = [];
|
|
2354
|
+
let agreed = 0;
|
|
2355
|
+
for (const e of store.treeList(mount.treeStateId)) {
|
|
2356
|
+
const p = agreement.get(name, e.path);
|
|
2357
|
+
if (p === undefined)
|
|
2358
|
+
(intents.get(e.path)?.origin === 'store' ? owed : unchecked).push(e.path);
|
|
2359
|
+
else if (p.kind !== 'content' || p.hash !== e.blobHash)
|
|
2360
|
+
owed.push(e.path);
|
|
2361
|
+
else
|
|
2362
|
+
agreed++;
|
|
2363
|
+
}
|
|
2364
|
+
return { owed, unchecked, agreed };
|
|
2365
|
+
}
|
|
1965
2366
|
async handleMaterialize(input) {
|
|
1966
2367
|
const store = this.getStore();
|
|
1967
2368
|
const allWritten = [];
|
|
2369
|
+
const allDeleted = [];
|
|
2370
|
+
const pendingDeletions = [];
|
|
2371
|
+
let explicit = null;
|
|
2372
|
+
if (input.path)
|
|
2373
|
+
explicit = this.parsePath(input.path);
|
|
1968
2374
|
let mountsToMaterialize;
|
|
1969
|
-
if (
|
|
2375
|
+
if (explicit) {
|
|
2376
|
+
mountsToMaterialize = [{ name: explicit.mount.config.name, mount: explicit.mount }];
|
|
2377
|
+
}
|
|
2378
|
+
else if (input.mount) {
|
|
1970
2379
|
const m = this.mounts.get(input.mount);
|
|
1971
2380
|
if (!m) {
|
|
1972
2381
|
return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
|
|
@@ -1981,59 +2390,94 @@ export class WorkspaceModule {
|
|
|
1981
2390
|
// Branch guard, scoped to the mounts actually being materialized: a
|
|
1982
2391
|
// linear continuation (current branch descends from the pinned branch at
|
|
1983
2392
|
// or after the pinned seq) passes; genuine divergence refuses unless
|
|
1984
|
-
// force. One mount's stale pin must never block another mount.
|
|
2393
|
+
// force. One mount's stale pin must never block another mount. It is
|
|
2394
|
+
// judged inside the mount's turn, on the same branch the selection is
|
|
2395
|
+
// made on: a materialize queued behind a pass would otherwise select on
|
|
2396
|
+
// whatever branch is current when its turn comes, unguarded. The push
|
|
2397
|
+
// then writes nothing if the branch changes after that.
|
|
1985
2398
|
const blocked = [];
|
|
2399
|
+
const guarded = [];
|
|
2400
|
+
let proceeded = 0;
|
|
1986
2401
|
for (const { name, mount } of mountsToMaterialize) {
|
|
1987
|
-
|
|
1988
|
-
|
|
2402
|
+
if (mount.config.mode === 'read-only') {
|
|
2403
|
+
// Only a mount the caller named gets here read-only (a bare
|
|
2404
|
+
// materialize takes the read-write ones): say why nothing was written.
|
|
2405
|
+
blocked.push({ mount: name, reason: 'a read-only mount: materialize writes nothing to it' });
|
|
1989
2406
|
continue;
|
|
1990
|
-
if (input.force) {
|
|
1991
|
-
// Disk reflects another line of history, so an incremental diff from
|
|
1992
|
-
// the pinned seq is meaningless — reset tracking and re-materialize
|
|
1993
|
-
// the full tree, exactly like materializeMount() after a deliberate
|
|
1994
|
-
// branch switch.
|
|
1995
|
-
mount.lastMaterializedSeq = 0;
|
|
1996
|
-
mount.lastMaterializedBranchId = null;
|
|
1997
2407
|
}
|
|
1998
|
-
|
|
1999
|
-
|
|
2408
|
+
const turn = await this.withMount(mount, async () => {
|
|
2409
|
+
const branchId = store.currentBranch().id;
|
|
2410
|
+
const reason = this.mountMaterializeBlockReason(store, mount);
|
|
2411
|
+
// Force passes a divergence. The selection follows the evidence, which
|
|
2412
|
+
// is per path and kept across branches, so nothing needs resetting:
|
|
2413
|
+
// the push re-pins to this branch.
|
|
2414
|
+
if (reason && !input.force)
|
|
2415
|
+
return { blocked: reason };
|
|
2416
|
+
const { paths, inPlace } = this.materializeSelection(mount, explicit?.relativePath ?? '');
|
|
2417
|
+
return {
|
|
2418
|
+
inPlace,
|
|
2419
|
+
pushed: await this.pushUnlocked(mount, paths, {
|
|
2420
|
+
force: input.force, named: explicit !== null, applyDeletions: input.applyDeletions, branchId,
|
|
2421
|
+
}),
|
|
2422
|
+
};
|
|
2423
|
+
});
|
|
2424
|
+
if ('blocked' in turn) {
|
|
2425
|
+
blocked.push({ mount: name, reason: turn.blocked });
|
|
2426
|
+
guarded.push(name);
|
|
2427
|
+
continue;
|
|
2000
2428
|
}
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
return {
|
|
2005
|
-
success: false,
|
|
2006
|
-
error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
|
|
2007
|
-
isError: true,
|
|
2008
|
-
};
|
|
2009
|
-
}
|
|
2010
|
-
for (const { name, mount } of mountsToMaterialize) {
|
|
2011
|
-
let paths;
|
|
2012
|
-
if (input.path) {
|
|
2013
|
-
const { relativePath } = this.parsePath(input.path);
|
|
2014
|
-
paths = relativePath ? [relativePath] : undefined;
|
|
2015
|
-
}
|
|
2016
|
-
// Suppress watcher for paths we're about to write
|
|
2017
|
-
const watcher = this.watchers.get(name);
|
|
2018
|
-
const written = await materializeToFs(store, mount, paths);
|
|
2019
|
-
for (const p of written) {
|
|
2020
|
-
watcher?.suppress(p);
|
|
2429
|
+
proceeded++;
|
|
2430
|
+
const pushed = turn.pushed;
|
|
2431
|
+
for (const p of pushed.written)
|
|
2021
2432
|
allWritten.push({ mount: name, path: p });
|
|
2022
|
-
|
|
2433
|
+
for (const p of pushed.deleted)
|
|
2434
|
+
allDeleted.push({ mount: name, path: p });
|
|
2435
|
+
for (const p of pushed.pendingDeletions)
|
|
2436
|
+
pendingDeletions.push({ mount: name, path: p });
|
|
2437
|
+
// Refusals ride the same skipped list as branch blocks: divergence must
|
|
2438
|
+
// be visible, not resolved silently either way.
|
|
2439
|
+
for (const s of pushed.skipped) {
|
|
2440
|
+
blocked.push({ mount: name, reason: `${s.path}: ${s.reason}` });
|
|
2441
|
+
}
|
|
2442
|
+
// What disk still owes is kept by its evidence, so the watermark and
|
|
2443
|
+
// the pin only feed the branch guard. Both are the push's own: what it
|
|
2444
|
+
// planned from is what disk now reflects, whatever branch was selected
|
|
2445
|
+
// while it wrote. A push that planned nothing (the selected branch
|
|
2446
|
+
// changed after its paths were chosen) left disk as it was, so it
|
|
2447
|
+
// leaves both as they were too.
|
|
2448
|
+
if (pushed.branchId === undefined || pushed.sequence === undefined)
|
|
2449
|
+
continue;
|
|
2450
|
+
mount.lastMaterializedSeq = pushed.sequence;
|
|
2023
2451
|
// Track which branch we materialized on. Re-pin on a clean empty
|
|
2024
2452
|
// materialize too (previously-pinned mount, nothing pending): disk
|
|
2025
2453
|
// already reflects the current branch's tree, and leaving the old pin
|
|
2026
2454
|
// would keep force required forever after a cross-branch materialize
|
|
2027
|
-
// that happened to write nothing.
|
|
2028
|
-
|
|
2029
|
-
|
|
2455
|
+
// that happened to write nothing. A first materialize pins once files
|
|
2456
|
+
// of this branch are in place, whether it found them on disk or the
|
|
2457
|
+
// evidence shows them there: disk holds this branch's tree.
|
|
2458
|
+
if (pushed.written.length > 0 || pushed.unchanged.length > 0 || turn.inPlace || mount.lastMaterializedBranchId !== null) {
|
|
2459
|
+
mount.lastMaterializedBranchId = pushed.branchId;
|
|
2030
2460
|
}
|
|
2031
2461
|
}
|
|
2462
|
+
if (proceeded === 0 && guarded.length > 0 && guarded.length === mountsToMaterialize.length) {
|
|
2463
|
+
return {
|
|
2464
|
+
success: false,
|
|
2465
|
+
error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
|
|
2466
|
+
isError: true,
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2032
2469
|
return {
|
|
2033
2470
|
success: true,
|
|
2034
2471
|
data: {
|
|
2035
2472
|
materialized: allWritten,
|
|
2036
2473
|
count: allWritten.length,
|
|
2474
|
+
...(allDeleted.length > 0 ? { deleted: allDeleted } : {}),
|
|
2475
|
+
...(pendingDeletions.length > 0
|
|
2476
|
+
? {
|
|
2477
|
+
workspaceDeletionsLeftOnDisk: pendingDeletions,
|
|
2478
|
+
note: 'Files deleted in the workspace were left on disk; materialize with applyDeletions to remove them.',
|
|
2479
|
+
}
|
|
2480
|
+
: {}),
|
|
2037
2481
|
...(blocked.length > 0 ? { skipped: blocked } : {}),
|
|
2038
2482
|
},
|
|
2039
2483
|
};
|
|
@@ -2044,6 +2488,8 @@ export class WorkspaceModule {
|
|
|
2044
2488
|
* Resets branch tracking to allow cross-branch materialization.
|
|
2045
2489
|
*/
|
|
2046
2490
|
async materializeMount(mountName) {
|
|
2491
|
+
if (!this.running())
|
|
2492
|
+
return [];
|
|
2047
2493
|
const store = this.getStore();
|
|
2048
2494
|
const mount = this.mounts.get(mountName);
|
|
2049
2495
|
if (!mount || mount.config.mode === 'read-only')
|
|
@@ -2051,53 +2497,114 @@ export class WorkspaceModule {
|
|
|
2051
2497
|
// Reset tracking — we're deliberately materializing on the new branch
|
|
2052
2498
|
mount.lastMaterializedBranchId = null;
|
|
2053
2499
|
mount.lastMaterializedSeq = 0;
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
mount
|
|
2500
|
+
// force: this path only runs after a deliberate undo/redo/branch switch
|
|
2501
|
+
// on the framework's own _config mount — restoring disk to the branch
|
|
2502
|
+
// state IS the operator intent, so the freshness guard yields. named:
|
|
2503
|
+
// the whole tree is that deliberate scope, entries without evidence too.
|
|
2504
|
+
const pushed = await this.withMount(mount, () => {
|
|
2505
|
+
const branchId = store.currentBranch().id;
|
|
2506
|
+
return this.pushUnlocked(mount, store.treeList(mount.treeStateId).map((e) => e.path), { force: true, named: true, branchId });
|
|
2507
|
+
});
|
|
2508
|
+
// A push that planned nothing leaves the reset tracking as it is.
|
|
2509
|
+
if (pushed.branchId !== undefined && pushed.sequence !== undefined) {
|
|
2510
|
+
mount.lastMaterializedSeq = pushed.sequence;
|
|
2511
|
+
if (pushed.written.length > 0 || pushed.unchanged.length > 0) {
|
|
2512
|
+
mount.lastMaterializedBranchId = pushed.branchId;
|
|
2513
|
+
}
|
|
2061
2514
|
}
|
|
2062
|
-
return written;
|
|
2515
|
+
return pushed.written;
|
|
2063
2516
|
}
|
|
2064
2517
|
async handleSync(input) {
|
|
2065
2518
|
const store = this.getStore();
|
|
2519
|
+
// A path sync adopts on the branch it was asked on, read before any await.
|
|
2520
|
+
const askedOn = store.currentBranch().id;
|
|
2066
2521
|
const allResults = [];
|
|
2067
2522
|
const allSkipped = [];
|
|
2068
|
-
|
|
2069
|
-
|
|
2523
|
+
const allIncomplete = [];
|
|
2524
|
+
const rootsAccepted = [];
|
|
2525
|
+
if (input.acceptRoot && input.path) {
|
|
2526
|
+
return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
|
|
2527
|
+
}
|
|
2528
|
+
// A path names its own mount; otherwise the given mount, or every mount.
|
|
2529
|
+
let targets;
|
|
2530
|
+
if (input.path) {
|
|
2531
|
+
const { mount, relativePath } = this.parsePath(input.path);
|
|
2532
|
+
targets = [{ name: mount.config.name, mount, relativePath }];
|
|
2533
|
+
}
|
|
2534
|
+
else if (input.mount) {
|
|
2070
2535
|
const m = this.mounts.get(input.mount);
|
|
2071
2536
|
if (!m) {
|
|
2072
2537
|
return { success: false, error: `Unknown mount: ${input.mount}`, isError: true };
|
|
2073
2538
|
}
|
|
2074
|
-
|
|
2539
|
+
targets = [{ name: input.mount, mount: m, relativePath: '' }];
|
|
2075
2540
|
}
|
|
2076
2541
|
else {
|
|
2077
|
-
|
|
2078
|
-
}
|
|
2079
|
-
for (const { name, mount } of
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2542
|
+
targets = [...this.mounts.entries()].map(([name, mount]) => ({ name, mount, relativePath: '' }));
|
|
2543
|
+
}
|
|
2544
|
+
for (const { name, mount, relativePath } of targets) {
|
|
2545
|
+
const pass = await this.withMount(mount, async () => {
|
|
2546
|
+
if (input.acceptRoot && await this.acceptRootUnlocked(mount))
|
|
2547
|
+
rootsAccepted.push(name);
|
|
2548
|
+
if (!relativePath) {
|
|
2549
|
+
// A full recheck: every file rehashed, nothing pending discarded.
|
|
2550
|
+
return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true }, { rehash: true });
|
|
2551
|
+
}
|
|
2552
|
+
// An explicit path: the workspace takes disk's state there.
|
|
2553
|
+
const prefix = relativePath + '/';
|
|
2554
|
+
const isDir = await lstat(join(mount.config.path, relativePath)).then((s) => s.isDirectory(), () => false) ||
|
|
2555
|
+
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
2556
|
+
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
2557
|
+
this.intents.get(name).list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
2558
|
+
const opts = { rehash: true, adopt: () => true, branchId: askedOn };
|
|
2559
|
+
if (!isDir)
|
|
2560
|
+
return this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
|
|
2561
|
+
const beneath = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }, opts);
|
|
2562
|
+
// Something at the path itself is no part of a directory scope: a file
|
|
2563
|
+
// disk has where the workspace holds a directory, or a workspace file
|
|
2564
|
+
// where disk has a directory. Its own pass takes disk's state there
|
|
2565
|
+
// too, after what lies beneath, so a file never sits over entries it
|
|
2566
|
+
// replaced.
|
|
2567
|
+
const onDisk = await lstat(join(mount.config.path, relativePath)).catch(() => null);
|
|
2568
|
+
const atPath = (onDisk !== null && !onDisk.isDirectory())
|
|
2569
|
+
|| store.treeGet(mount.treeStateId, relativePath) !== null
|
|
2570
|
+
|| this.agreementOrThrow().get(name, relativePath) !== undefined
|
|
2571
|
+
|| this.intents.get(name).get(relativePath) !== null;
|
|
2572
|
+
if (!atPath)
|
|
2573
|
+
return beneath;
|
|
2574
|
+
const itself = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
|
|
2575
|
+
return {
|
|
2576
|
+
ops: [...beneath.ops, ...itself.ops],
|
|
2577
|
+
discarded: [...beneath.discarded, ...itself.discarded],
|
|
2578
|
+
newConflicts: [...beneath.newConflicts, ...itself.newConflicts],
|
|
2579
|
+
reports: new Map([...beneath.reports, ...itself.reports]),
|
|
2580
|
+
incomplete: [...beneath.incomplete, ...itself.incomplete],
|
|
2581
|
+
dirs: beneath.dirs,
|
|
2582
|
+
branchId: itself.branchId ?? beneath.branchId,
|
|
2583
|
+
};
|
|
2584
|
+
});
|
|
2585
|
+
const conflicts = [...pass.reports.values()]
|
|
2586
|
+
.filter((r) => r.state === 'conflict')
|
|
2587
|
+
.map((r) => ({ path: r.path, kind: r.conflict?.kind ?? 'both-changed', diskCopy: r.conflict?.diskCopy ?? 'referenced' }));
|
|
2588
|
+
if (pass.ops.length > 0 || conflicts.length > 0 || pass.discarded.length > 0) {
|
|
2089
2589
|
allResults.push({
|
|
2090
2590
|
mount: name,
|
|
2091
|
-
synced:
|
|
2092
|
-
conflicts
|
|
2591
|
+
synced: pass.ops.map((o) => o.path),
|
|
2592
|
+
conflicts,
|
|
2593
|
+
...(pass.discarded.length > 0 ? { discarded: pass.discarded } : {}),
|
|
2093
2594
|
});
|
|
2094
|
-
this.emitFsEvents(name, result.synced, result.conflicts);
|
|
2095
2595
|
}
|
|
2096
|
-
//
|
|
2097
|
-
// "
|
|
2098
|
-
for (const
|
|
2099
|
-
|
|
2596
|
+
// Say why a path wasn't taken in, so "nothing synced" and "your file was
|
|
2597
|
+
// refused" don't look identical from the outside.
|
|
2598
|
+
for (const r of pass.reports.values()) {
|
|
2599
|
+
if (r.state === 'unverified') {
|
|
2600
|
+
allSkipped.push({ mount: name, path: r.path, reason: `disk could not be checked: ${r.note ?? 'not observed'}` });
|
|
2601
|
+
}
|
|
2602
|
+
else if (r.state === 'disk-only') {
|
|
2603
|
+
allSkipped.push({ mount: name, path: r.path, reason: 'disk-only: binary or over the size limit, never stored' });
|
|
2604
|
+
}
|
|
2100
2605
|
}
|
|
2606
|
+
for (const region of pass.incomplete)
|
|
2607
|
+
allIncomplete.push({ mount: name, ...region });
|
|
2101
2608
|
}
|
|
2102
2609
|
return {
|
|
2103
2610
|
success: true,
|
|
@@ -2106,80 +2613,208 @@ export class WorkspaceModule {
|
|
|
2106
2613
|
totalSynced: allResults.reduce((sum, r) => sum + r.synced.length, 0),
|
|
2107
2614
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
2108
2615
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
2616
|
+
...(allIncomplete.length > 0 ? { incomplete: allIncomplete } : {}),
|
|
2617
|
+
...(rootsAccepted.length > 0 ? { rootsAccepted } : {}),
|
|
2109
2618
|
},
|
|
2110
2619
|
};
|
|
2111
2620
|
}
|
|
2621
|
+
/**
|
|
2622
|
+
* Take the mount's root as it is now (acceptRoot), when it is a directory
|
|
2623
|
+
* other than the one disk last agreed under. Its evidence is set aside:
|
|
2624
|
+
* it described another directory. Returns whether a root was accepted.
|
|
2625
|
+
*/
|
|
2626
|
+
async acceptRootUnlocked(mount) {
|
|
2627
|
+
const runtime = await this.runtime(mount);
|
|
2628
|
+
const found = await locateRoot({ ...runtime.view, rootIdentity: undefined }, runtime.rootReal);
|
|
2629
|
+
if ('reason' in found)
|
|
2630
|
+
return false; // still missing: the pass says so
|
|
2631
|
+
const agreement = this.agreementOrThrow();
|
|
2632
|
+
const recorded = agreement.rootIdentity(mount.config.name);
|
|
2633
|
+
if (recorded && sameRootIdentity(recorded, found.identity))
|
|
2634
|
+
return false;
|
|
2635
|
+
if (recorded)
|
|
2636
|
+
agreement.acceptRoot(mount.config.name, found.identity);
|
|
2637
|
+
else
|
|
2638
|
+
agreement.recordRootIdentity(mount.config.name, found.identity);
|
|
2639
|
+
agreement.barrier();
|
|
2640
|
+
return true;
|
|
2641
|
+
}
|
|
2112
2642
|
// ==========================================================================
|
|
2113
2643
|
// Internal: Filesystem Change Handling
|
|
2114
2644
|
// ==========================================================================
|
|
2115
2645
|
/**
|
|
2116
2646
|
* Handle filesystem changes detected by watcher (watch: 'always' mode).
|
|
2117
2647
|
*
|
|
2118
|
-
* The watcher
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2121
|
-
* op type.
|
|
2648
|
+
* The watcher's ops only choose which paths to look at: each path is
|
|
2649
|
+
* observed again and decided under the three-way rule, so an event that
|
|
2650
|
+
* arrives late or out of order can't delete or revert anything by itself.
|
|
2122
2651
|
*/
|
|
2123
2652
|
async handleFsChanges(mountName, changes) {
|
|
2124
|
-
const store = this.store;
|
|
2125
2653
|
const mount = this.mounts.get(mountName);
|
|
2126
|
-
if (!store || !mount)
|
|
2654
|
+
if (!this.store || !this.running() || !mount)
|
|
2127
2655
|
return;
|
|
2128
|
-
const
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
: { synced: [], conflicts: [], skipped: [] };
|
|
2133
|
-
// Strip deleted entries from the tree so downstream consumers see a
|
|
2134
|
-
// coherent state. syncFromFs would also do this if we passed the paths in,
|
|
2135
|
-
// but the op from the watcher is authoritative — skip the access() probe.
|
|
2136
|
-
for (const p of deletedPaths) {
|
|
2137
|
-
const existing = store.treeGet(mount.treeStateId, p);
|
|
2138
|
-
if (existing) {
|
|
2139
|
-
store.treeRemove(mount.treeStateId, p);
|
|
2140
|
-
syncResult.synced.push({ path: p, op: 'deleted' });
|
|
2141
|
-
}
|
|
2142
|
-
}
|
|
2143
|
-
this.emitFsEvents(mountName, syncResult.synced, syncResult.conflicts);
|
|
2656
|
+
const paths = [...new Set(changes.map((c) => c.path))];
|
|
2657
|
+
if (paths.length === 0)
|
|
2658
|
+
return;
|
|
2659
|
+
await this.withMount(mount, () => this.passUnlocked(mount, { kind: 'paths', paths }));
|
|
2144
2660
|
}
|
|
2145
2661
|
/**
|
|
2146
|
-
*
|
|
2662
|
+
* After an agent's completed tool batch, before anything continues its turn:
|
|
2663
|
+
* scan every `watch: 'on-agent-action'` mount, so what the batch's tools did
|
|
2664
|
+
* on disk (a shell command's files, a deletion) is in the workspace when the
|
|
2665
|
+
* agent next looks. A scan that outlasts the deadline stops holding the
|
|
2666
|
+
* round: the miss is recorded on the mount's status and pushed as a
|
|
2667
|
+
* `workspace:agent-action-scan-incomplete` event, and the scan continues,
|
|
2668
|
+
* deciding on the branch selected when it applies.
|
|
2147
2669
|
*/
|
|
2148
|
-
|
|
2149
|
-
|
|
2670
|
+
async onToolBatchComplete(_agentName) {
|
|
2671
|
+
// Every agent's workspace is the store's current branch, so the agent
|
|
2672
|
+
// that ran the batch doesn't choose what is scanned.
|
|
2673
|
+
const mounts = [...this.mounts.values()].filter((m) => m.config.watch === 'on-agent-action');
|
|
2674
|
+
if (mounts.length === 0 || !this.store || !this.running())
|
|
2150
2675
|
return;
|
|
2151
|
-
const
|
|
2152
|
-
|
|
2153
|
-
const
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2676
|
+
const deadlineMs = this.agentActionScanDeadlineMs();
|
|
2677
|
+
await Promise.all(mounts.map(async (mount) => {
|
|
2678
|
+
const runningLate = mount.agentActionScanLate !== undefined;
|
|
2679
|
+
const scan = this.agentActionScan(mount);
|
|
2680
|
+
if (runningLate) {
|
|
2681
|
+
// An earlier batch's scan outlasted its deadline and still runs: this
|
|
2682
|
+
// round isn't held as well. Its own scan is queued after that one,
|
|
2683
|
+
// and records its outcome when it finishes. It is late from the
|
|
2684
|
+
// start, so a batch that comes while it runs isn't held either.
|
|
2685
|
+
this.agentActionScanLateUntil(mount, scan);
|
|
2686
|
+
this.agentActionScanMissed(mount, 'an earlier scan is still running past its deadline; this batch is scanned after it');
|
|
2687
|
+
return;
|
|
2688
|
+
}
|
|
2689
|
+
let timer;
|
|
2690
|
+
const deadline = new Promise((resolveDeadline) => {
|
|
2691
|
+
timer = setTimeout(() => resolveDeadline('late'), deadlineMs);
|
|
2692
|
+
});
|
|
2693
|
+
const outcome = await Promise.race([scan.settled.then(() => 'done'), deadline]);
|
|
2694
|
+
clearTimeout(timer);
|
|
2695
|
+
if (outcome === 'late') {
|
|
2696
|
+
this.agentActionScanLateUntil(mount, scan);
|
|
2697
|
+
this.agentActionScanMissed(mount, `deadline exceeded (${deadlineMs} ms); the scan continues`);
|
|
2172
2698
|
}
|
|
2699
|
+
}));
|
|
2700
|
+
}
|
|
2701
|
+
/**
|
|
2702
|
+
* Mark a scan late, and the mount as running late until it settles. The
|
|
2703
|
+
* marker follows the newest late scan: a mount's scans run one after
|
|
2704
|
+
* another, so the newest is the last to settle, and a batch that comes
|
|
2705
|
+
* while any late scan of the chain still runs isn't held.
|
|
2706
|
+
*/
|
|
2707
|
+
agentActionScanLateUntil(mount, scan) {
|
|
2708
|
+
scan.late = true;
|
|
2709
|
+
const settled = scan.settled;
|
|
2710
|
+
mount.agentActionScanLate = settled;
|
|
2711
|
+
void settled.then(() => { if (mount.agentActionScanLate === settled)
|
|
2712
|
+
mount.agentActionScanLate = undefined; });
|
|
2713
|
+
}
|
|
2714
|
+
/** A batch's scan missed its deadline, or failed: said on status and as an event. */
|
|
2715
|
+
agentActionScanMissed(mount, reason) {
|
|
2716
|
+
mount.lastAgentActionScan = { at: Date.now(), complete: false, withinDeadline: false, reason };
|
|
2717
|
+
this.ctx?.pushEvent({ type: 'workspace:agent-action-scan-incomplete', mount: mount.config.name, reason });
|
|
2718
|
+
}
|
|
2719
|
+
/**
|
|
2720
|
+
* The mount's after-batch scan: one may run and one wait behind it. A batch
|
|
2721
|
+
* that comes while one waits shares it, since it starts after the batch's
|
|
2722
|
+
* tools ran. Each scan records its own outcome when it settles: on status
|
|
2723
|
+
* while it is the mount's newest scan (a scan queued behind it describes a
|
|
2724
|
+
* later batch), and as an event for regions it couldn't observe (the file
|
|
2725
|
+
* cap, an unreadable or changing region, a branch that kept changing) or a
|
|
2726
|
+
* failure, whichever scan it is.
|
|
2727
|
+
*/
|
|
2728
|
+
agentActionScan(mount) {
|
|
2729
|
+
if (mount.agentActionScanWaiting)
|
|
2730
|
+
return mount.agentActionScanWaiting;
|
|
2731
|
+
const scan = { late: false, settled: Promise.resolve() };
|
|
2732
|
+
const pass = this.withMount(mount, () => {
|
|
2733
|
+
if (mount.agentActionScanWaiting === scan)
|
|
2734
|
+
mount.agentActionScanWaiting = undefined;
|
|
2735
|
+
return this.passUnlocked(mount, { kind: 'dir', dir: '', recursive: true });
|
|
2736
|
+
});
|
|
2737
|
+
scan.settled = pass.then((result) => {
|
|
2738
|
+
if (result.incomplete.length > 0) {
|
|
2739
|
+
this.ctx?.pushEvent({
|
|
2740
|
+
type: 'workspace:agent-action-scan-incomplete',
|
|
2741
|
+
mount: mount.config.name,
|
|
2742
|
+
reason: 'the scan finished, but some regions could not be observed',
|
|
2743
|
+
incomplete: result.incomplete,
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
if (mount.agentActionScanLatest !== scan)
|
|
2747
|
+
return;
|
|
2748
|
+
const incomplete = result.incomplete.length > 0 ? { incomplete: result.incomplete } : {};
|
|
2749
|
+
mount.lastAgentActionScan = scan.late
|
|
2750
|
+
? { at: Date.now(), complete: true, withinDeadline: false, reason: 'finished after the deadline', ...incomplete }
|
|
2751
|
+
: { at: Date.now(), complete: true, withinDeadline: true, ...incomplete };
|
|
2752
|
+
}, (err) => {
|
|
2753
|
+
const reason = `scan failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
2754
|
+
if (mount.agentActionScanLatest === scan)
|
|
2755
|
+
this.agentActionScanMissed(mount, reason);
|
|
2756
|
+
else
|
|
2757
|
+
this.ctx?.pushEvent({ type: 'workspace:agent-action-scan-incomplete', mount: mount.config.name, reason });
|
|
2758
|
+
});
|
|
2759
|
+
mount.agentActionScanWaiting = scan;
|
|
2760
|
+
mount.agentActionScanLatest = scan;
|
|
2761
|
+
return scan;
|
|
2762
|
+
}
|
|
2763
|
+
/** The scan deadline, kept below the framework's hook bound (warned once). */
|
|
2764
|
+
agentActionScanDeadlineMs() {
|
|
2765
|
+
const configured = this.config.agentActionScanDeadlineMs ?? AGENT_ACTION_SCAN_DEADLINE_MS;
|
|
2766
|
+
if (configured <= AGENT_ACTION_SCAN_DEADLINE_MAX_MS)
|
|
2767
|
+
return configured;
|
|
2768
|
+
if (!this.warnedScanDeadline) {
|
|
2769
|
+
this.warnedScanDeadline = true;
|
|
2770
|
+
console.warn(`[workspace] agentActionScanDeadlineMs ${configured} leaves less than 1 s before the framework's ` +
|
|
2771
|
+
`${TOOL_BATCH_HOOK_TIMEOUT_MS} ms bound on a tool-batch hook; using ${AGENT_ACTION_SCAN_DEADLINE_MAX_MS} ms`);
|
|
2772
|
+
}
|
|
2773
|
+
return AGENT_ACTION_SCAN_DEADLINE_MAX_MS;
|
|
2774
|
+
}
|
|
2775
|
+
/**
|
|
2776
|
+
* Push one ProcessEvent per op for a pass's tree changes and new conflicts.
|
|
2777
|
+
* A conflict rides the event for the disk change behind it (modified, or
|
|
2778
|
+
* deleted for a disk deletion of a file with newer workspace changes).
|
|
2779
|
+
*/
|
|
2780
|
+
emitFsEvents(mountName, ops, newConflicts = []) {
|
|
2781
|
+
if (!this.ctx || (ops.length === 0 && newConflicts.length === 0))
|
|
2782
|
+
return;
|
|
2783
|
+
const byOp = new Map();
|
|
2784
|
+
const add = (op, path, conflict) => {
|
|
2785
|
+
const group = byOp.get(op) ?? { paths: [], conflicts: [] };
|
|
2786
|
+
const full = `${mountName}/${path}`;
|
|
2787
|
+
if (!group.paths.includes(full))
|
|
2788
|
+
group.paths.push(full);
|
|
2789
|
+
if (conflict)
|
|
2790
|
+
group.conflicts.push(full);
|
|
2791
|
+
byOp.set(op, group);
|
|
2792
|
+
};
|
|
2793
|
+
for (const { path, op } of ops)
|
|
2794
|
+
add(op, path, false);
|
|
2795
|
+
for (const { path, op } of newConflicts)
|
|
2796
|
+
add(op, path, true);
|
|
2797
|
+
for (const [op, { paths, conflicts }] of byOp) {
|
|
2173
2798
|
const event = {
|
|
2174
|
-
type,
|
|
2799
|
+
type: opToEventType(op),
|
|
2175
2800
|
paths,
|
|
2176
2801
|
mount: mountName,
|
|
2177
|
-
...(
|
|
2802
|
+
...(conflicts.length > 0 ? { conflicts } : {}),
|
|
2178
2803
|
};
|
|
2179
2804
|
this.ctx.pushEvent(event);
|
|
2180
2805
|
}
|
|
2181
2806
|
}
|
|
2182
2807
|
}
|
|
2808
|
+
/** What a listing shows of a path beyond its name. */
|
|
2809
|
+
function stateView(report) {
|
|
2810
|
+
return {
|
|
2811
|
+
state: report.state,
|
|
2812
|
+
...(report.size !== undefined ? { size: report.size } : {}),
|
|
2813
|
+
...(report.mimeType ? { mimeType: report.mimeType } : {}),
|
|
2814
|
+
...(report.conflict ? { conflict: report.conflict } : {}),
|
|
2815
|
+
...(report.note ? { note: report.note } : {}),
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2183
2818
|
// ==========================================================================
|
|
2184
2819
|
// Utilities
|
|
2185
2820
|
// ==========================================================================
|