@animalabs/agent-framework 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent.d.ts +11 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +60 -4
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +72 -1
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +873 -123
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +7 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +15 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/kv-unified-wire.d.ts +16 -0
- package/dist/src/kv-unified-wire.d.ts.map +1 -0
- package/dist/src/kv-unified-wire.js +2 -0
- package/dist/src/kv-unified-wire.js.map +1 -0
- package/dist/src/mcpl/channel-registry.d.ts +84 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +188 -5
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +28 -2
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +6 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +29 -2
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +59 -0
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -0
- package/dist/src/mcpl/reference-fetcher.js +240 -0
- package/dist/src/mcpl/reference-fetcher.js.map +1 -0
- package/dist/src/mcpl/references.d.ts +125 -0
- package/dist/src/mcpl/references.d.ts.map +1 -0
- package/dist/src/mcpl/references.js +262 -0
- package/dist/src/mcpl/references.js.map +1 -0
- package/dist/src/mcpl/types.d.ts +32 -0
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +125 -2
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +354 -58
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +7 -0
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +14 -0
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
- package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
- package/dist/src/tool-wrapper-prose-guard.js +37 -0
- package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
- package/dist/src/tune-out/coordinator.d.ts +144 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -0
- package/dist/src/tune-out/coordinator.js +444 -0
- package/dist/src/tune-out/coordinator.js.map +1 -0
- package/dist/src/tune-out/tools.d.ts +43 -0
- package/dist/src/tune-out/tools.d.ts.map +1 -0
- package/dist/src/tune-out/tools.js +93 -0
- package/dist/src/tune-out/tools.js.map +1 -0
- package/dist/src/types/agent.d.ts +15 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +6 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/usage-accounting.d.ts +5 -0
- package/dist/src/usage-accounting.d.ts.map +1 -0
- package/dist/src/usage-accounting.js +11 -0
- package/dist/src/usage-accounting.js.map +1 -0
- package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
- package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
- package/dist/test/channel-incoming-targeting.test.js +81 -0
- package/dist/test/channel-incoming-targeting.test.js.map +1 -0
- package/dist/test/conversation-routing.test.js +39 -0
- package/dist/test/conversation-routing.test.js.map +1 -1
- package/dist/test/kv-unified-wiring.test.d.ts +2 -0
- package/dist/test/kv-unified-wiring.test.d.ts.map +1 -0
- package/dist/test/kv-unified-wiring.test.js +56 -0
- package/dist/test/kv-unified-wiring.test.js.map +1 -0
- package/dist/test/mcpl-push-convert.test.d.ts +2 -0
- package/dist/test/mcpl-push-convert.test.d.ts.map +1 -0
- package/dist/test/mcpl-push-convert.test.js +42 -0
- package/dist/test/mcpl-push-convert.test.js.map +1 -0
- package/dist/test/mcpl-reference-fetcher.test.d.ts +2 -0
- package/dist/test/mcpl-reference-fetcher.test.d.ts.map +1 -0
- package/dist/test/mcpl-reference-fetcher.test.js +199 -0
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -0
- package/dist/test/per-agent-delivery.test.d.ts +2 -0
- package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
- package/dist/test/per-agent-delivery.test.js +108 -0
- package/dist/test/per-agent-delivery.test.js.map +1 -0
- package/dist/test/present-while-acting.test.js +64 -0
- package/dist/test/present-while-acting.test.js.map +1 -1
- package/dist/test/tool-result-history.test.js +78 -0
- package/dist/test/tool-result-history.test.js.map +1 -1
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
- package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
- package/dist/test/tune-out-e2e.test.d.ts +2 -0
- package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
- package/dist/test/tune-out-e2e.test.js +142 -0
- package/dist/test/tune-out-e2e.test.js.map +1 -0
- package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
- package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
- package/dist/test/tune-out-gate-composition.test.js +221 -0
- package/dist/test/tune-out-gate-composition.test.js.map +1 -0
- package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
- package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
- package/dist/test/tune-out-lifecycle.test.js +121 -0
- package/dist/test/tune-out-lifecycle.test.js.map +1 -0
- package/dist/test/turn-trigger-provenance.test.d.ts +2 -0
- package/dist/test/turn-trigger-provenance.test.d.ts.map +1 -0
- package/dist/test/turn-trigger-provenance.test.js +85 -0
- package/dist/test/turn-trigger-provenance.test.js.map +1 -0
- package/dist/test/usage-accounting.test.d.ts +2 -0
- package/dist/test/usage-accounting.test.d.ts.map +1 -0
- package/dist/test/usage-accounting.test.js +13 -0
- package/dist/test/usage-accounting.test.js.map +1 -0
- package/dist/test/workspace-branch-continuation.test.d.ts +15 -0
- package/dist/test/workspace-branch-continuation.test.d.ts.map +1 -0
- package/dist/test/workspace-branch-continuation.test.js +183 -0
- package/dist/test/workspace-branch-continuation.test.js.map +1 -0
- package/dist/test/workspace-read-file-from-disk.test.d.ts +17 -0
- package/dist/test/workspace-read-file-from-disk.test.d.ts.map +1 -0
- package/dist/test/workspace-read-file-from-disk.test.js +174 -0
- package/dist/test/workspace-read-file-from-disk.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/test/workspace-restore-order.test.js +1 -1
- package/dist/test/workspace-restore-order.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +77 -4
- package/src/framework.ts +953 -128
- package/src/gate/event-gate.ts +28 -3
- package/src/kv-unified-wire.ts +12 -0
- package/src/mcpl/channel-registry.ts +264 -8
- package/src/mcpl/hook-orchestrator.ts +29 -2
- package/src/mcpl/push-handler.ts +30 -2
- package/src/mcpl/reference-fetcher.ts +240 -0
- package/src/mcpl/references.ts +292 -0
- package/src/mcpl/types.ts +29 -0
- package/src/modules/index.ts +2 -1
- package/src/modules/workspace/index.ts +425 -66
- package/src/modules/workspace/types.ts +7 -0
- package/src/tool-result-history.ts +11 -0
- package/src/tool-wrapper-prose-guard.ts +38 -0
- package/src/tune-out/coordinator.ts +548 -0
- package/src/tune-out/tools.ts +129 -0
- package/src/types/agent.ts +16 -0
- package/src/types/framework.ts +7 -0
- package/src/usage-accounting.ts +8 -0
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { constants as fsConstants } from 'node:fs';
|
|
9
|
+
import type { Stats } from 'node:fs';
|
|
9
10
|
import { open, readFile, stat, access, writeFile, unlink, mkdir, lstat, realpath } from 'node:fs/promises';
|
|
10
11
|
import { join, resolve, relative, dirname, sep } from 'node:path';
|
|
11
12
|
import type { JsStore } from '@animalabs/chronicle';
|
|
@@ -78,6 +79,82 @@ class WorkspaceImageReadError extends Error {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Why a workspace-owned filesystem read was refused. Distinguishes the cases a
|
|
84
|
+
* peer needs to react to differently: a path that never resolved inside the
|
|
85
|
+
* mount (`unknown_mount`, `traversal`), a mount whose root is gone
|
|
86
|
+
* (`mount_unavailable`), an ordinary missing file (`not_found`), a directory
|
|
87
|
+
* (`directory`), a symlink refused by the mount's `followSymlinks` policy
|
|
88
|
+
* (`symlink`), a symlink whose canonical target lands outside the canonical
|
|
89
|
+
* mount root (`escape`), and a file swapped underneath the read (`changed`).
|
|
90
|
+
*/
|
|
91
|
+
export type WorkspaceReadErrorCode =
|
|
92
|
+
| 'unknown_mount'
|
|
93
|
+
| 'traversal'
|
|
94
|
+
| 'mount_unavailable'
|
|
95
|
+
| 'not_found'
|
|
96
|
+
| 'directory'
|
|
97
|
+
| 'symlink'
|
|
98
|
+
| 'escape'
|
|
99
|
+
| 'changed';
|
|
100
|
+
|
|
101
|
+
/** Where in the open sequence a `WorkspaceReadError` originated (diagnostics). */
|
|
102
|
+
export type WorkspaceReadStage =
|
|
103
|
+
| 'parse'
|
|
104
|
+
| 'lstat'
|
|
105
|
+
| 'realpath_root'
|
|
106
|
+
| 'open'
|
|
107
|
+
| 'fstat'
|
|
108
|
+
| 'post_lstat'
|
|
109
|
+
| 'realpath'
|
|
110
|
+
| 'stat'
|
|
111
|
+
| 'read';
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Thrown by `WorkspaceModule.readFileFromDisk()`. Messages carry the
|
|
115
|
+
* mount-prefixed path only — never absolute filesystem paths — so they can be
|
|
116
|
+
* surfaced to the agent or logged without leaking host layout.
|
|
117
|
+
*/
|
|
118
|
+
export class WorkspaceReadError extends Error {
|
|
119
|
+
constructor(
|
|
120
|
+
readonly code: WorkspaceReadErrorCode,
|
|
121
|
+
message: string,
|
|
122
|
+
readonly stage: WorkspaceReadStage,
|
|
123
|
+
/** Underlying errno code (e.g. 'ENOENT', 'ELOOP') when a syscall failed. */
|
|
124
|
+
readonly errno?: string,
|
|
125
|
+
) {
|
|
126
|
+
super(message);
|
|
127
|
+
this.name = 'WorkspaceReadError';
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Successful `readFileFromDisk()` result. */
|
|
132
|
+
export interface WorkspaceDiskReadResult {
|
|
133
|
+
/** File bytes — the whole file, or its first `maxBytes` when `truncated`. */
|
|
134
|
+
bytes: Buffer;
|
|
135
|
+
/** Full on-disk size in bytes, regardless of truncation. */
|
|
136
|
+
size: number;
|
|
137
|
+
/** True when `maxBytes` was set and the file was larger; `bytes` is a prefix. */
|
|
138
|
+
truncated: boolean;
|
|
139
|
+
/** mtime of the file actually read (for change-detection caches). */
|
|
140
|
+
mtimeMs: number;
|
|
141
|
+
/** Canonical (realpath) location of the file read — inside the canonical mount root by construction. */
|
|
142
|
+
realPath: string;
|
|
143
|
+
/** Mount the path resolved into. */
|
|
144
|
+
mount: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface ReadFileFromDiskOptions {
|
|
148
|
+
/** Read at most this many bytes (a bounded prefix read — the rest of the file is never loaded). */
|
|
149
|
+
maxBytes?: number;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function errnoOf(err: unknown): string | undefined {
|
|
153
|
+
return typeof err === 'object' && err !== null && 'code' in err && typeof (err as { code?: unknown }).code === 'string'
|
|
154
|
+
? (err as { code: string }).code
|
|
155
|
+
: undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
81
158
|
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
82
159
|
const JPEG_SIGNATURE = Buffer.from([0xff, 0xd8, 0xff]);
|
|
83
160
|
const GIF87A_SIGNATURE = Buffer.from('GIF87a', 'ascii');
|
|
@@ -292,9 +369,13 @@ function validateJpeg(bytes: Buffer, mountPrefixedPath: string): void {
|
|
|
292
369
|
offset += 1;
|
|
293
370
|
|
|
294
371
|
if (marker === JPEG_EOI) {
|
|
295
|
-
if (!sawSof || !sawSos
|
|
372
|
+
if (!sawSof || !sawSos) {
|
|
296
373
|
invalidImage('JPEG', mountPrefixedPath);
|
|
297
374
|
}
|
|
375
|
+
// Bytes after EOI are tolerated: hardware encoders (e.g. Raspberry Pi
|
|
376
|
+
// camera stills) pad each frame to a 4-byte boundary with NULs after
|
|
377
|
+
// the EOI marker, and every mainstream decoder stops at EOI. The image
|
|
378
|
+
// proper (SOI..EOI) has been fully validated by this point.
|
|
298
379
|
return;
|
|
299
380
|
}
|
|
300
381
|
if (marker === JPEG_TEM || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
@@ -637,6 +718,95 @@ export class WorkspaceModule implements Module {
|
|
|
637
718
|
}
|
|
638
719
|
}
|
|
639
720
|
this.savedState = null;
|
|
721
|
+
|
|
722
|
+
// Absorb out-of-band branch switches (e.g. an offline repair that left
|
|
723
|
+
// the store on a child branch): if the pinned branch is a strict ancestor
|
|
724
|
+
// of the current branch and disk state is fully contained in the current
|
|
725
|
+
// branch's history, re-pin. Without this, every materialize refuses
|
|
726
|
+
// forever after a repair, even though nothing on disk can be clobbered.
|
|
727
|
+
this.healBranchPins();
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* True when everything last materialized to disk for a mount is part of the
|
|
732
|
+
* CURRENT branch's history — i.e. the current branch is a linear
|
|
733
|
+
* continuation of the pinned branch as of the pinned sequence.
|
|
734
|
+
*
|
|
735
|
+
* Walks the current branch's parent chain. Safe iff the pinned branch is on
|
|
736
|
+
* the chain AND every fork point along the way is at or after `pinnedSeq`
|
|
737
|
+
* (a fork before `pinnedSeq` means disk holds records the current branch
|
|
738
|
+
* never had — genuine divergence). A missing/GC'd intermediate branch or a
|
|
739
|
+
* child without fork metadata is treated as unsafe: ancestry can't be
|
|
740
|
+
* proven, so the guard stays closed and `force` is the escape hatch.
|
|
741
|
+
*/
|
|
742
|
+
private isLinearContinuation(
|
|
743
|
+
store: JsStore,
|
|
744
|
+
pinnedBranchId: string,
|
|
745
|
+
pinnedSeq: number,
|
|
746
|
+
): boolean {
|
|
747
|
+
const current = store.currentBranch();
|
|
748
|
+
if (current.id === pinnedBranchId) return true;
|
|
749
|
+
const byId = new Map(store.listBranches().map((b) => [b.id, b]));
|
|
750
|
+
const visited = new Set<string>();
|
|
751
|
+
let cursor = byId.get(current.id);
|
|
752
|
+
while (cursor && !visited.has(cursor.id)) {
|
|
753
|
+
visited.add(cursor.id);
|
|
754
|
+
if (cursor.id === pinnedBranchId) return true;
|
|
755
|
+
if (cursor.parentId === undefined || cursor.branchPoint === undefined) return false;
|
|
756
|
+
if (cursor.branchPoint < pinnedSeq) return false;
|
|
757
|
+
cursor = byId.get(cursor.parentId);
|
|
758
|
+
}
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Why materializing this mount is blocked on the current branch, or null if
|
|
764
|
+
* it isn't. Single source of truth for the materialize guard AND the
|
|
765
|
+
* `canMaterialize` status field, so status can never report `true` for a
|
|
766
|
+
* mount that materialize would refuse (the pre-fix defect: status computed
|
|
767
|
+
* per-mount id-equality while the guard checked ALL mounts).
|
|
768
|
+
*/
|
|
769
|
+
private mountMaterializeBlockReason(store: JsStore, mount: MountState): string | null {
|
|
770
|
+
if (this.config.materializeOnlyActiveBranch === false) return null;
|
|
771
|
+
if (!mount.lastMaterializedBranchId) return null;
|
|
772
|
+
const current = store.currentBranch();
|
|
773
|
+
if (mount.lastMaterializedBranchId === current.id) return null;
|
|
774
|
+
if (this.isLinearContinuation(store, mount.lastMaterializedBranchId, mount.lastMaterializedSeq)) {
|
|
775
|
+
return null;
|
|
776
|
+
}
|
|
777
|
+
const pinned = store.listBranches().find((b) => b.id === mount.lastMaterializedBranchId);
|
|
778
|
+
const pinnedName = pinned ? `"${pinned.name}"` : `id ${mount.lastMaterializedBranchId} (branch no longer exists)`;
|
|
779
|
+
return (
|
|
780
|
+
`current branch "${current.name}" has diverged from the branch last materialized to disk ` +
|
|
781
|
+
`(${pinnedName} at seq ${mount.lastMaterializedSeq}) — disk may hold state the current branch ` +
|
|
782
|
+
`never had. Pass force: true to overwrite disk from the current branch if it is canonical.`
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Re-pin mounts whose last-materialized branch is a proven ancestor of the
|
|
788
|
+
* current branch. Runs after persisted state is applied on restart, so an
|
|
789
|
+
* out-of-band branch switch (offline repair/treatment branches) heals at
|
|
790
|
+
* boot instead of wedging materialize until manual surgery.
|
|
791
|
+
*/
|
|
792
|
+
private healBranchPins(): void {
|
|
793
|
+
const store = this.store;
|
|
794
|
+
if (!store) return;
|
|
795
|
+
const current = store.currentBranch();
|
|
796
|
+
for (const mount of this.mounts.values()) {
|
|
797
|
+
if (
|
|
798
|
+
mount.lastMaterializedBranchId &&
|
|
799
|
+
mount.lastMaterializedBranchId !== current.id &&
|
|
800
|
+
this.isLinearContinuation(store, mount.lastMaterializedBranchId, mount.lastMaterializedSeq)
|
|
801
|
+
) {
|
|
802
|
+
console.warn(
|
|
803
|
+
`[workspace] Mount "${mount.config.name}": branch pin ${mount.lastMaterializedBranchId} → ` +
|
|
804
|
+
`${current.id} ("${current.name}") — current branch linearly continues the last ` +
|
|
805
|
+
`materialized branch (out-of-band switch, e.g. repair); disk state is preserved history.`,
|
|
806
|
+
);
|
|
807
|
+
mount.lastMaterializedBranchId = current.id;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
640
810
|
}
|
|
641
811
|
|
|
642
812
|
/**
|
|
@@ -877,6 +1047,7 @@ export class WorkspaceModule implements Module {
|
|
|
877
1047
|
properties: {
|
|
878
1048
|
path: { type: 'string', description: 'Specific path to materialize (optional — defaults to all changed)' },
|
|
879
1049
|
mount: { type: 'string', description: 'Specific mount (optional)' },
|
|
1050
|
+
force: { type: 'boolean', description: 'Materialize even if the current branch has diverged from the branch last written to disk (default false). Only needed for genuine divergence — descendant branches pass automatically.' },
|
|
880
1051
|
},
|
|
881
1052
|
},
|
|
882
1053
|
},
|
|
@@ -994,8 +1165,17 @@ export class WorkspaceModule implements Module {
|
|
|
994
1165
|
/**
|
|
995
1166
|
* Resolve a mount-prefixed path (e.g. "tickets/2026-04-22-foo.md") to its
|
|
996
1167
|
* absolute filesystem path. Returns null if the mount is unknown or the
|
|
997
|
-
* resolved path escapes the mount root
|
|
998
|
-
*
|
|
1168
|
+
* resolved path escapes the mount root.
|
|
1169
|
+
*
|
|
1170
|
+
* **Lexical containment only.** The guard reasons about `..` segments in the
|
|
1171
|
+
* path string; it knows nothing about what is on disk. A symlink inside the
|
|
1172
|
+
* mount that targets an outside file passes this check, and a caller that
|
|
1173
|
+
* then does `fs.readFile(path)` reads the outside content. Use the returned
|
|
1174
|
+
* path for resolution-only purposes (write targets, nonexistent paths,
|
|
1175
|
+
* display); for reading file content, use {@link readFileFromDisk}, which
|
|
1176
|
+
* enforces the mount's `followSymlinks` policy and canonical containment.
|
|
1177
|
+
*
|
|
1178
|
+
* @deprecated for direct filesystem reads — call `readFileFromDisk()`.
|
|
999
1179
|
*/
|
|
1000
1180
|
resolveAbsolutePath(mountPrefixedPath: string): string | null {
|
|
1001
1181
|
try {
|
|
@@ -1189,82 +1369,107 @@ export class WorkspaceModule implements Module {
|
|
|
1189
1369
|
return this.validateImageBytes(blob, mountPrefixedPath, maxSize);
|
|
1190
1370
|
}
|
|
1191
1371
|
|
|
1192
|
-
|
|
1372
|
+
/**
|
|
1373
|
+
* Open a mount-relative file for reading with the mount boundary enforced on
|
|
1374
|
+
* the *filesystem*, not just the path string:
|
|
1375
|
+
*
|
|
1376
|
+
* 1. `lstat` the lexical path — refuse directories; refuse a final-component
|
|
1377
|
+
* symlink when the mount does not `followSymlinks`.
|
|
1378
|
+
* 2. Open with `O_NOFOLLOW` where the platform has it (POSIX) so the refusal
|
|
1379
|
+
* holds against a symlink swapped in between lstat and open; where it
|
|
1380
|
+
* doesn't (Windows), re-`lstat` after opening instead.
|
|
1381
|
+
* 3. `realpath` both the mount root and the opened path and require canonical
|
|
1382
|
+
* containment — this is what catches an *intermediate* symlinked
|
|
1383
|
+
* directory escaping the mount, and a followed symlink whose target lands
|
|
1384
|
+
* outside, on every platform (junctions included).
|
|
1385
|
+
* 4. Confirm the descriptor and the canonical path are the same inode.
|
|
1386
|
+
*
|
|
1387
|
+
* `inspect` runs right after the descriptor is stat'd and before the
|
|
1388
|
+
* containment work, so callers can impose size policy in the same order the
|
|
1389
|
+
* image reader always has. The returned handle is the caller's to close.
|
|
1390
|
+
*/
|
|
1391
|
+
private async openContainedFile(
|
|
1193
1392
|
mount: MountState,
|
|
1194
1393
|
relativePath: string,
|
|
1195
1394
|
mountPrefixedPath: string,
|
|
1196
|
-
|
|
1197
|
-
): Promise<{
|
|
1395
|
+
inspect?: (fileStat: Stats) => void,
|
|
1396
|
+
): Promise<{ handle: Awaited<ReturnType<typeof open>>; fileStat: Stats; realFilePath: string }> {
|
|
1198
1397
|
const lexicalPath = resolve(mount.config.path, relativePath);
|
|
1199
|
-
const
|
|
1398
|
+
const follow = mount.config.followSymlinks === true;
|
|
1399
|
+
const useNoFollow = !follow && typeof fsConstants.O_NOFOLLOW === 'number';
|
|
1200
1400
|
|
|
1201
1401
|
let fileInfo: Awaited<ReturnType<typeof lstat>>;
|
|
1202
1402
|
try {
|
|
1203
1403
|
fileInfo = await lstat(lexicalPath);
|
|
1204
1404
|
} catch (err) {
|
|
1205
|
-
|
|
1206
|
-
|
|
1405
|
+
const code = errnoOf(err);
|
|
1406
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
1407
|
+
throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'lstat', code);
|
|
1207
1408
|
}
|
|
1208
|
-
throw new
|
|
1409
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'lstat', code);
|
|
1209
1410
|
}
|
|
1210
|
-
|
|
1211
1411
|
if (fileInfo.isDirectory()) {
|
|
1212
|
-
throw new
|
|
1412
|
+
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'lstat');
|
|
1213
1413
|
}
|
|
1214
|
-
if (fileInfo.isSymbolicLink() && !
|
|
1215
|
-
throw new
|
|
1414
|
+
if (fileInfo.isSymbolicLink() && !follow) {
|
|
1415
|
+
throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'lstat');
|
|
1216
1416
|
}
|
|
1217
1417
|
|
|
1218
1418
|
let realMountRoot: string;
|
|
1219
1419
|
try {
|
|
1220
1420
|
realMountRoot = await realpath(mount.config.path);
|
|
1221
|
-
} catch {
|
|
1222
|
-
throw new
|
|
1421
|
+
} catch (err) {
|
|
1422
|
+
throw new WorkspaceReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`, 'realpath_root', errnoOf(err));
|
|
1223
1423
|
}
|
|
1224
1424
|
|
|
1225
|
-
let handle: Awaited<ReturnType<typeof open
|
|
1425
|
+
let handle: Awaited<ReturnType<typeof open>>;
|
|
1226
1426
|
try {
|
|
1227
1427
|
handle = await open(lexicalPath, fsConstants.O_RDONLY | (useNoFollow ? fsConstants.O_NOFOLLOW : 0));
|
|
1228
1428
|
} catch (err) {
|
|
1229
|
-
|
|
1230
|
-
|
|
1429
|
+
const code = errnoOf(err);
|
|
1430
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
1431
|
+
throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'open', code);
|
|
1231
1432
|
}
|
|
1232
|
-
if (!
|
|
1233
|
-
throw new
|
|
1433
|
+
if (!follow && code === 'ELOOP') {
|
|
1434
|
+
throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'open', code);
|
|
1234
1435
|
}
|
|
1235
|
-
throw new
|
|
1436
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'open', code);
|
|
1236
1437
|
}
|
|
1237
1438
|
|
|
1238
1439
|
try {
|
|
1239
|
-
|
|
1440
|
+
let fileStat: Stats;
|
|
1441
|
+
try {
|
|
1442
|
+
fileStat = await handle.stat();
|
|
1443
|
+
} catch (err) {
|
|
1444
|
+
const code = errnoOf(err);
|
|
1445
|
+
throw new WorkspaceReadError(
|
|
1446
|
+
'not_found',
|
|
1447
|
+
code === 'ENOENT' ? `File not found: ${mountPrefixedPath}` : `Unable to read file: ${mountPrefixedPath}`,
|
|
1448
|
+
'fstat',
|
|
1449
|
+
code,
|
|
1450
|
+
);
|
|
1451
|
+
}
|
|
1240
1452
|
if (fileStat.isDirectory()) {
|
|
1241
|
-
throw new
|
|
1453
|
+
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'fstat');
|
|
1242
1454
|
}
|
|
1243
1455
|
if (!fileStat.isFile()) {
|
|
1244
|
-
throw new
|
|
1245
|
-
}
|
|
1246
|
-
if (fileStat.size === 0) {
|
|
1247
|
-
throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
|
|
1248
|
-
}
|
|
1249
|
-
if (fileStat.size > maxSize) {
|
|
1250
|
-
throw new WorkspaceImageReadError(
|
|
1251
|
-
'too_large',
|
|
1252
|
-
`Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
|
|
1253
|
-
);
|
|
1456
|
+
throw new WorkspaceReadError('not_found', `File not found: ${mountPrefixedPath}`, 'fstat');
|
|
1254
1457
|
}
|
|
1458
|
+
inspect?.(fileStat);
|
|
1255
1459
|
|
|
1256
|
-
if (!
|
|
1460
|
+
if (!follow && !useNoFollow) {
|
|
1257
1461
|
let postOpenInfo: Awaited<ReturnType<typeof lstat>>;
|
|
1258
1462
|
try {
|
|
1259
1463
|
postOpenInfo = await lstat(lexicalPath);
|
|
1260
1464
|
} catch (err) {
|
|
1261
|
-
|
|
1262
|
-
|
|
1465
|
+
const code = errnoOf(err);
|
|
1466
|
+
if (code === 'ENOENT') {
|
|
1467
|
+
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'post_lstat', code);
|
|
1263
1468
|
}
|
|
1264
|
-
throw new
|
|
1469
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'post_lstat', code);
|
|
1265
1470
|
}
|
|
1266
1471
|
if (postOpenInfo.isSymbolicLink()) {
|
|
1267
|
-
throw new
|
|
1472
|
+
throw new WorkspaceReadError('symlink', `Symlinks are not allowed: ${mountPrefixedPath}`, 'post_lstat');
|
|
1268
1473
|
}
|
|
1269
1474
|
}
|
|
1270
1475
|
|
|
@@ -1272,29 +1477,162 @@ export class WorkspaceModule implements Module {
|
|
|
1272
1477
|
try {
|
|
1273
1478
|
realFilePath = await realpath(lexicalPath);
|
|
1274
1479
|
} catch (err) {
|
|
1275
|
-
|
|
1276
|
-
|
|
1480
|
+
const code = errnoOf(err);
|
|
1481
|
+
if (code === 'ENOENT') {
|
|
1482
|
+
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'realpath', code);
|
|
1277
1483
|
}
|
|
1278
|
-
throw new
|
|
1484
|
+
throw new WorkspaceReadError('not_found', `Unable to resolve file: ${mountPrefixedPath}`, 'realpath', code);
|
|
1279
1485
|
}
|
|
1280
1486
|
|
|
1281
1487
|
if (!isContainedPath(realMountRoot, realFilePath)) {
|
|
1282
|
-
throw new
|
|
1488
|
+
throw new WorkspaceReadError('escape', `Symlink escape detected: ${mountPrefixedPath}`, 'realpath');
|
|
1283
1489
|
}
|
|
1284
1490
|
|
|
1285
|
-
let pathStat:
|
|
1491
|
+
let pathStat: Stats;
|
|
1286
1492
|
try {
|
|
1287
1493
|
pathStat = await stat(realFilePath);
|
|
1288
1494
|
} catch (err) {
|
|
1289
|
-
|
|
1290
|
-
|
|
1495
|
+
const code = errnoOf(err);
|
|
1496
|
+
if (code === 'ENOENT') {
|
|
1497
|
+
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat', code);
|
|
1291
1498
|
}
|
|
1292
|
-
throw new
|
|
1499
|
+
throw new WorkspaceReadError('not_found', `Unable to stat file: ${mountPrefixedPath}`, 'stat', code);
|
|
1293
1500
|
}
|
|
1294
1501
|
if (pathStat.dev !== fileStat.dev || pathStat.ino !== fileStat.ino) {
|
|
1295
|
-
throw new
|
|
1502
|
+
throw new WorkspaceReadError('changed', `File changed during read: ${mountPrefixedPath}`, 'stat');
|
|
1296
1503
|
}
|
|
1297
1504
|
|
|
1505
|
+
return { handle, fileStat, realFilePath };
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
await handle.close();
|
|
1508
|
+
throw err;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/**
|
|
1513
|
+
* Read a workspace file from disk with the mount boundary enforced on the
|
|
1514
|
+
* filesystem (see {@link openContainedFile}). Public API for peer modules
|
|
1515
|
+
* that read mounted files outside the Chronicle tree — the safe replacement
|
|
1516
|
+
* for `resolveAbsolutePath()` + `fs.readFile()`, whose lexical-only guard let
|
|
1517
|
+
* an in-mount symlink smuggle outside content into context (agent-framework
|
|
1518
|
+
* #129, found via connectome-host #101).
|
|
1519
|
+
*
|
|
1520
|
+
* - Honors the mount's `followSymlinks` policy (default: refuse).
|
|
1521
|
+
* - With symlinks allowed, the canonical target must stay beneath the
|
|
1522
|
+
* canonical mount root; a sibling-prefix root (`/mount-other`) does not count.
|
|
1523
|
+
* - `maxBytes` performs a bounded prefix read: at most `maxBytes` bytes are
|
|
1524
|
+
* ever loaded, and `truncated` reports that the file was larger.
|
|
1525
|
+
*
|
|
1526
|
+
* Throws {@link WorkspaceReadError} with a `code` distinguishing unknown
|
|
1527
|
+
* mount, lexical traversal, unavailable mount, missing file, directory,
|
|
1528
|
+
* policy-denied symlink, outside-mount target, and file-changed-during-read.
|
|
1529
|
+
*/
|
|
1530
|
+
async readFileFromDisk(
|
|
1531
|
+
mountPrefixedPath: string,
|
|
1532
|
+
options: ReadFileFromDiskOptions = {},
|
|
1533
|
+
): Promise<WorkspaceDiskReadResult> {
|
|
1534
|
+
if (options.maxBytes !== undefined && !(Number.isInteger(options.maxBytes) && options.maxBytes >= 0)) {
|
|
1535
|
+
throw new RangeError('readFileFromDisk: maxBytes must be a non-negative integer');
|
|
1536
|
+
}
|
|
1537
|
+
let mount: MountState;
|
|
1538
|
+
let relativePath: string;
|
|
1539
|
+
try {
|
|
1540
|
+
({ mount, relativePath } = this.parsePath(mountPrefixedPath));
|
|
1541
|
+
} catch (err) {
|
|
1542
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1543
|
+
const code: WorkspaceReadErrorCode = message.startsWith('Unknown mount') ? 'unknown_mount' : 'traversal';
|
|
1544
|
+
throw new WorkspaceReadError(code, message, 'parse');
|
|
1545
|
+
}
|
|
1546
|
+
if (!relativePath) {
|
|
1547
|
+
throw new WorkspaceReadError('directory', `Path is a directory: ${mountPrefixedPath}`, 'parse');
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const { handle, fileStat, realFilePath } = await this.openContainedFile(mount, relativePath, mountPrefixedPath);
|
|
1551
|
+
try {
|
|
1552
|
+
const size = fileStat.size;
|
|
1553
|
+
const cap = options.maxBytes;
|
|
1554
|
+
let bytes: Buffer;
|
|
1555
|
+
let truncated = false;
|
|
1556
|
+
if (cap !== undefined && size > cap) {
|
|
1557
|
+
truncated = true;
|
|
1558
|
+
bytes = Buffer.alloc(cap);
|
|
1559
|
+
let offset = 0;
|
|
1560
|
+
while (offset < cap) {
|
|
1561
|
+
const { bytesRead } = await handle.read(bytes, offset, cap - offset, offset);
|
|
1562
|
+
if (bytesRead === 0) break;
|
|
1563
|
+
offset += bytesRead;
|
|
1564
|
+
}
|
|
1565
|
+
if (offset < cap) bytes = bytes.subarray(0, offset);
|
|
1566
|
+
} else {
|
|
1567
|
+
bytes = await handle.readFile();
|
|
1568
|
+
}
|
|
1569
|
+
return { bytes, size, truncated, mtimeMs: fileStat.mtimeMs, realPath: realFilePath, mount: mount.config.name };
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
if (err instanceof WorkspaceReadError) throw err;
|
|
1572
|
+
throw new WorkspaceReadError('not_found', `Unable to read file: ${mountPrefixedPath}`, 'read', errnoOf(err));
|
|
1573
|
+
} finally {
|
|
1574
|
+
await handle.close();
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/** Map a core read refusal onto the image reader's historical error vocabulary and wording. */
|
|
1579
|
+
private imageErrorFromRead(err: WorkspaceReadError, mount: MountState, mountPrefixedPath: string): WorkspaceImageReadError {
|
|
1580
|
+
const p = mountPrefixedPath;
|
|
1581
|
+
switch (err.code) {
|
|
1582
|
+
case 'mount_unavailable':
|
|
1583
|
+
return new WorkspaceImageReadError('mount_unavailable', `Mount unavailable: ${mount.config.name}`);
|
|
1584
|
+
case 'directory':
|
|
1585
|
+
return new WorkspaceImageReadError('directory', `Path is a directory: ${p}`);
|
|
1586
|
+
case 'symlink':
|
|
1587
|
+
return new WorkspaceImageReadError('symlink', `Symlinks are not allowed: ${p}`);
|
|
1588
|
+
case 'escape':
|
|
1589
|
+
return new WorkspaceImageReadError('escape', `Symlink escape detected: ${p}`);
|
|
1590
|
+
case 'changed':
|
|
1591
|
+
return new WorkspaceImageReadError('changed', `Image file changed during read: ${p}`);
|
|
1592
|
+
case 'not_found':
|
|
1593
|
+
default:
|
|
1594
|
+
if (err.message.startsWith('File not found')) {
|
|
1595
|
+
return new WorkspaceImageReadError('not_found', `File not found: ${p}`);
|
|
1596
|
+
}
|
|
1597
|
+
if (err.stage === 'realpath') {
|
|
1598
|
+
return new WorkspaceImageReadError('not_found', `Unable to resolve image file: ${p}`);
|
|
1599
|
+
}
|
|
1600
|
+
if (err.stage === 'stat') {
|
|
1601
|
+
return new WorkspaceImageReadError('not_found', `Unable to stat image file: ${p}`);
|
|
1602
|
+
}
|
|
1603
|
+
return new WorkspaceImageReadError('not_found', `Unable to read image file: ${p}`);
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
private async readImageFromFilesystem(
|
|
1608
|
+
mount: MountState,
|
|
1609
|
+
relativePath: string,
|
|
1610
|
+
mountPrefixedPath: string,
|
|
1611
|
+
maxSize: number,
|
|
1612
|
+
): Promise<{ bytes: Buffer; mimeType: SupportedImageMimeType }> {
|
|
1613
|
+
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
1614
|
+
try {
|
|
1615
|
+
opened = await this.openContainedFile(mount, relativePath, mountPrefixedPath, (fileStat) => {
|
|
1616
|
+
if (fileStat.size === 0) {
|
|
1617
|
+
throw new WorkspaceImageReadError('empty', `Image file is empty: ${mountPrefixedPath}`);
|
|
1618
|
+
}
|
|
1619
|
+
if (fileStat.size > maxSize) {
|
|
1620
|
+
throw new WorkspaceImageReadError(
|
|
1621
|
+
'too_large',
|
|
1622
|
+
`Image file exceeds max size (${maxSize} bytes): ${mountPrefixedPath}`,
|
|
1623
|
+
);
|
|
1624
|
+
}
|
|
1625
|
+
});
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
if (err instanceof WorkspaceImageReadError) throw err;
|
|
1628
|
+
if (err instanceof WorkspaceReadError) throw this.imageErrorFromRead(err, mount, mountPrefixedPath);
|
|
1629
|
+
if (isErrnoCode(err, 'ENOENT')) {
|
|
1630
|
+
throw new WorkspaceImageReadError('not_found', `File not found: ${mountPrefixedPath}`);
|
|
1631
|
+
}
|
|
1632
|
+
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
1633
|
+
}
|
|
1634
|
+
const { handle } = opened;
|
|
1635
|
+
try {
|
|
1298
1636
|
const bytes = await handle.readFile();
|
|
1299
1637
|
return this.validateImageBytes(bytes, mountPrefixedPath, maxSize);
|
|
1300
1638
|
} catch (err) {
|
|
@@ -1304,7 +1642,7 @@ export class WorkspaceModule implements Module {
|
|
|
1304
1642
|
}
|
|
1305
1643
|
throw new WorkspaceImageReadError('not_found', `Unable to read image file: ${mountPrefixedPath}`);
|
|
1306
1644
|
} finally {
|
|
1307
|
-
await handle
|
|
1645
|
+
await handle.close();
|
|
1308
1646
|
}
|
|
1309
1647
|
}
|
|
1310
1648
|
|
|
@@ -1863,7 +2201,7 @@ export class WorkspaceModule implements Module {
|
|
|
1863
2201
|
initialSyncDone: mount.initialSyncDone,
|
|
1864
2202
|
currentBranch: currentBranch.name,
|
|
1865
2203
|
lastMaterializedBranch: mount.lastMaterializedBranchId,
|
|
1866
|
-
canMaterialize:
|
|
2204
|
+
canMaterialize: this.mountMaterializeBlockReason(store, mount) === null,
|
|
1867
2205
|
};
|
|
1868
2206
|
}
|
|
1869
2207
|
|
|
@@ -1873,20 +2211,6 @@ export class WorkspaceModule implements Module {
|
|
|
1873
2211
|
private async handleMaterialize(input: MaterializeInput): Promise<ToolResult> {
|
|
1874
2212
|
const store = this.getStore();
|
|
1875
2213
|
|
|
1876
|
-
// Guard: only materialize on the active branch
|
|
1877
|
-
if (this.config.materializeOnlyActiveBranch !== false) {
|
|
1878
|
-
const currentBranch = store.currentBranch();
|
|
1879
|
-
for (const mount of this.mounts.values()) {
|
|
1880
|
-
if (mount.lastMaterializedBranchId && mount.lastMaterializedBranchId !== currentBranch.id) {
|
|
1881
|
-
return {
|
|
1882
|
-
success: false,
|
|
1883
|
-
error: `Cannot materialize: current branch "${currentBranch.name}" differs from last materialized branch. Switch back or set materializeOnlyActiveBranch: false.`,
|
|
1884
|
-
isError: true,
|
|
1885
|
-
};
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
2214
|
const allWritten: Array<{ mount: string; path: string }> = [];
|
|
1891
2215
|
|
|
1892
2216
|
let mountsToMaterialize: Array<{ name: string; mount: MountState }>;
|
|
@@ -1902,6 +2226,36 @@ export class WorkspaceModule implements Module {
|
|
|
1902
2226
|
.map(([name, mount]) => ({ name, mount }));
|
|
1903
2227
|
}
|
|
1904
2228
|
|
|
2229
|
+
// Branch guard, scoped to the mounts actually being materialized: a
|
|
2230
|
+
// linear continuation (current branch descends from the pinned branch at
|
|
2231
|
+
// or after the pinned seq) passes; genuine divergence refuses unless
|
|
2232
|
+
// force. One mount's stale pin must never block another mount.
|
|
2233
|
+
const blocked: Array<{ mount: string; reason: string }> = [];
|
|
2234
|
+
for (const { name, mount } of mountsToMaterialize) {
|
|
2235
|
+
const reason = this.mountMaterializeBlockReason(store, mount);
|
|
2236
|
+
if (!reason) continue;
|
|
2237
|
+
if (input.force) {
|
|
2238
|
+
// Disk reflects another line of history, so an incremental diff from
|
|
2239
|
+
// the pinned seq is meaningless — reset tracking and re-materialize
|
|
2240
|
+
// the full tree, exactly like materializeMount() after a deliberate
|
|
2241
|
+
// branch switch.
|
|
2242
|
+
mount.lastMaterializedSeq = 0;
|
|
2243
|
+
mount.lastMaterializedBranchId = null;
|
|
2244
|
+
} else {
|
|
2245
|
+
blocked.push({ mount: name, reason });
|
|
2246
|
+
}
|
|
2247
|
+
}
|
|
2248
|
+
mountsToMaterialize = mountsToMaterialize.filter(
|
|
2249
|
+
({ name }) => !blocked.some((b) => b.mount === name),
|
|
2250
|
+
);
|
|
2251
|
+
if (mountsToMaterialize.length === 0 && blocked.length > 0) {
|
|
2252
|
+
return {
|
|
2253
|
+
success: false,
|
|
2254
|
+
error: `Cannot materialize: ${blocked.map((b) => `[${b.mount}] ${b.reason}`).join('; ')}`,
|
|
2255
|
+
isError: true,
|
|
2256
|
+
};
|
|
2257
|
+
}
|
|
2258
|
+
|
|
1905
2259
|
for (const { name, mount } of mountsToMaterialize) {
|
|
1906
2260
|
|
|
1907
2261
|
let paths: string[] | undefined;
|
|
@@ -1919,8 +2273,12 @@ export class WorkspaceModule implements Module {
|
|
|
1919
2273
|
allWritten.push({ mount: name, path: p });
|
|
1920
2274
|
}
|
|
1921
2275
|
|
|
1922
|
-
// Track which branch we materialized on
|
|
1923
|
-
|
|
2276
|
+
// Track which branch we materialized on. Re-pin on a clean empty
|
|
2277
|
+
// materialize too (previously-pinned mount, nothing pending): disk
|
|
2278
|
+
// already reflects the current branch's tree, and leaving the old pin
|
|
2279
|
+
// would keep force required forever after a cross-branch materialize
|
|
2280
|
+
// that happened to write nothing.
|
|
2281
|
+
if (written.length > 0 || mount.lastMaterializedBranchId !== null) {
|
|
1924
2282
|
mount.lastMaterializedBranchId = store.currentBranch().id;
|
|
1925
2283
|
}
|
|
1926
2284
|
}
|
|
@@ -1930,6 +2288,7 @@ export class WorkspaceModule implements Module {
|
|
|
1930
2288
|
data: {
|
|
1931
2289
|
materialized: allWritten,
|
|
1932
2290
|
count: allWritten.length,
|
|
2291
|
+
...(blocked.length > 0 ? { skipped: blocked } : {}),
|
|
1933
2292
|
},
|
|
1934
2293
|
};
|
|
1935
2294
|
}
|
|
@@ -200,6 +200,13 @@ export interface MaterializeInput {
|
|
|
200
200
|
path?: string;
|
|
201
201
|
/** Specific mount (optional — defaults to all read-write mounts) */
|
|
202
202
|
mount?: string;
|
|
203
|
+
/**
|
|
204
|
+
* Materialize even when the current branch has genuinely diverged from the
|
|
205
|
+
* branch last materialized to disk (default: false). Not needed for linear
|
|
206
|
+
* continuations (child branches forked at or after the last materialized
|
|
207
|
+
* point) — those pass the guard automatically.
|
|
208
|
+
*/
|
|
209
|
+
force?: boolean;
|
|
203
210
|
}
|
|
204
211
|
|
|
205
212
|
export interface SyncInput {
|