@animalabs/agent-framework 0.22.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +41 -4
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/framework.d.ts +319 -20
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1500 -182
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.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.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +2 -1
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +20 -9
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +29 -6
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +57 -16
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +28 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +32 -4
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/module-registry.d.ts +9 -3
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +3 -2
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +46 -4
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/observe.d.ts +13 -3
- package/dist/src/modules/workspace/observe.d.ts.map +1 -1
- package/dist/src/modules/workspace/observe.js +24 -4
- package/dist/src/modules/workspace/observe.js.map +1 -1
- package/dist/src/modules/workspace/reconcile.d.ts +14 -0
- package/dist/src/modules/workspace/reconcile.d.ts.map +1 -1
- package/dist/src/modules/workspace/reconcile.js +10 -2
- package/dist/src/modules/workspace/reconcile.js.map +1 -1
- package/dist/src/operator-log.d.ts +41 -0
- package/dist/src/operator-log.d.ts.map +1 -1
- package/dist/src/operator-log.js.map +1 -1
- package/dist/src/provider-waits.d.ts +216 -0
- package/dist/src/provider-waits.d.ts.map +1 -0
- package/dist/src/provider-waits.js +482 -0
- package/dist/src/provider-waits.js.map +1 -0
- package/dist/src/refusal-reactions.d.ts +14 -12
- package/dist/src/refusal-reactions.d.ts.map +1 -1
- package/dist/src/refusal-reactions.js +14 -12
- package/dist/src/refusal-reactions.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +30 -13
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +71 -25
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/types/events.d.ts +12 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +12 -4
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +14 -3
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/discord-awareness-outbox.test.js +3 -0
- package/dist/test/discord-awareness-outbox.test.js.map +1 -1
- package/dist/test/explicit-prose-routing.test.js +0 -2
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js +160 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -1
- package/dist/test/helpers/coalescing-fixture.js +3 -2
- package/dist/test/helpers/coalescing-fixture.js.map +1 -1
- package/dist/test/inference-exhaustion-rewind.test.js +142 -14
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -1
- package/dist/test/inference-failure-bounds.test.d.ts +2 -0
- package/dist/test/inference-failure-bounds.test.d.ts.map +1 -0
- package/dist/test/inference-failure-bounds.test.js +1201 -0
- package/dist/test/inference-failure-bounds.test.js.map +1 -0
- package/dist/test/late-events-after-stop.test.d.ts +2 -0
- package/dist/test/late-events-after-stop.test.d.ts.map +1 -0
- package/dist/test/late-events-after-stop.test.js +122 -0
- package/dist/test/late-events-after-stop.test.js.map +1 -0
- package/dist/test/mcpl-awareness-barrier.test.js +141 -0
- package/dist/test/mcpl-awareness-barrier.test.js.map +1 -1
- package/dist/test/mcpl-coalescing.test.js +78 -16
- package/dist/test/mcpl-coalescing.test.js.map +1 -1
- package/dist/test/mcpl-config-diagnostics.test.js +6 -3
- package/dist/test/mcpl-config-diagnostics.test.js.map +1 -1
- package/dist/test/mcpl-manifest-changed.test.js +2 -1
- package/dist/test/mcpl-manifest-changed.test.js.map +1 -1
- package/dist/test/mcpl-reference-fetcher.test.js +232 -4
- package/dist/test/mcpl-reference-fetcher.test.js.map +1 -1
- package/dist/test/mcpl-reference-fields.test.js +69 -2
- package/dist/test/mcpl-reference-fields.test.js.map +1 -1
- package/dist/test/mcpl-ws-transport.test.js +103 -0
- package/dist/test/mcpl-ws-transport.test.js.map +1 -1
- package/dist/test/ops-alert.test.js +2 -1
- package/dist/test/ops-alert.test.js.map +1 -1
- package/dist/test/placed-reaction-baseline.test.d.ts +2 -0
- package/dist/test/placed-reaction-baseline.test.d.ts.map +1 -0
- package/dist/test/placed-reaction-baseline.test.js +312 -0
- package/dist/test/placed-reaction-baseline.test.js.map +1 -0
- package/dist/test/provider-acceleration-cooldown.test.js +5 -1
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -1
- package/dist/test/provider-waits.test.d.ts +2 -0
- package/dist/test/provider-waits.test.d.ts.map +1 -0
- package/dist/test/provider-waits.test.js +2196 -0
- package/dist/test/provider-waits.test.js.map +1 -0
- package/dist/test/reaction-baseline-platform.test.d.ts +2 -0
- package/dist/test/reaction-baseline-platform.test.d.ts.map +1 -0
- package/dist/test/reaction-baseline-platform.test.js +124 -0
- package/dist/test/reaction-baseline-platform.test.js.map +1 -0
- package/dist/test/refusal-reaction-baseline.test.js +10 -3
- package/dist/test/refusal-reaction-baseline.test.js.map +1 -1
- package/dist/test/save-recent-image.test.js +138 -2
- package/dist/test/save-recent-image.test.js.map +1 -1
- package/dist/test/surgery-marks-receipt.test.d.ts +2 -0
- package/dist/test/surgery-marks-receipt.test.d.ts.map +1 -0
- package/dist/test/surgery-marks-receipt.test.js +540 -0
- package/dist/test/surgery-marks-receipt.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.js +221 -7
- package/dist/test/tool-result-guard.test.js.map +1 -1
- package/dist/test/workspace-three-way.test.js +115 -0
- package/dist/test/workspace-three-way.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/agent.ts +4 -1
- package/src/api/server.ts +43 -4
- package/src/framework.ts +1538 -189
- package/src/index.ts +7 -2
- package/src/mcpl/index.ts +2 -0
- package/src/mcpl/reference-fetcher.ts +18 -8
- package/src/mcpl/references.ts +57 -13
- package/src/mcpl/server-connection.ts +55 -4
- package/src/module-registry.ts +17 -9
- package/src/modules/workspace/index.ts +44 -4
- package/src/modules/workspace/observe.ts +31 -5
- package/src/modules/workspace/reconcile.ts +23 -2
- package/src/operator-log.ts +30 -0
- package/src/provider-waits.ts +523 -0
- package/src/refusal-reactions.ts +14 -12
- package/src/tool-result-guard.ts +72 -24
- package/src/types/events.ts +12 -0
- package/src/types/module.ts +12 -4
- package/src/types/trace.ts +14 -3
package/src/index.ts
CHANGED
|
@@ -92,7 +92,7 @@ export type { McplServerConfig } from './mcpl/index.js';
|
|
|
92
92
|
// health probes, protocol harnesses — can dial an MCPL server exactly the
|
|
93
93
|
// way the framework host does, over stdio or WebSocket)
|
|
94
94
|
export { McplServerConnection } from './mcpl/index.js';
|
|
95
|
-
export type { McplHostCapabilities } from './mcpl/index.js';
|
|
95
|
+
export type { McplHostCapabilities, McplConnectionCloseInfo } from './mcpl/index.js';
|
|
96
96
|
|
|
97
97
|
// MCPL tool lifecycle (RFC-007) and tool classes (RFC-008): the types a host
|
|
98
98
|
// needs to write McplServerConfig.toolLifecycle policy and class tables, and
|
|
@@ -143,7 +143,12 @@ export type {
|
|
|
143
143
|
} from './recovery/discord-awareness-outbox.js';
|
|
144
144
|
// Live operator surgery (rollback / suppress) and its durable action log
|
|
145
145
|
export { OperatorLog, OperatorActionError, defaultOperatorLogPath } from './operator-log.js';
|
|
146
|
-
export type {
|
|
146
|
+
export type {
|
|
147
|
+
OperatorLogEntry,
|
|
148
|
+
OperatorLogInput,
|
|
149
|
+
OperatorRequester,
|
|
150
|
+
SurgeryMarkerReceipt,
|
|
151
|
+
} from './operator-log.js';
|
|
147
152
|
export { createOfflineRecoveryBranch } from './recovery/offline-branch.js';
|
|
148
153
|
export type {
|
|
149
154
|
OfflineRecoveryBranchOptions,
|
package/src/mcpl/index.ts
CHANGED
|
@@ -107,6 +107,8 @@ export {
|
|
|
107
107
|
|
|
108
108
|
// Server connection and registry
|
|
109
109
|
export { McplServerConnection } from './server-connection.js';
|
|
110
|
+
export type { McplConnectionCloseInfo } from './server-connection.js';
|
|
111
|
+
export type { TransportCloseInfo } from './transport.js';
|
|
110
112
|
export { McplServerRegistry, type McplCapabilityQuery } from './server-registry.js';
|
|
111
113
|
|
|
112
114
|
// Feature set management (permission layer)
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { createHash } from 'node:crypto';
|
|
31
31
|
import type { ReferenceRecord } from './references.js';
|
|
32
|
-
import { isReferenceExpired } from './references.js';
|
|
32
|
+
import { EXPIRY_REASON, isReferenceExpired, referenceExpiry, REFERENCE_LIMITS, sanitizeLabel } from './references.js';
|
|
33
33
|
|
|
34
34
|
/** Per-fetch hard ceiling on actual bytes (overridable per server / call). */
|
|
35
35
|
export const DEFAULT_FETCH_MAX_BYTES = 256 * 1024 * 1024;
|
|
@@ -79,13 +79,14 @@ export function sniffMime(data: Buffer): string | null {
|
|
|
79
79
|
return null;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
/** A Map, so a server's type such as `constructor` finds no inherited key. */
|
|
83
|
+
const EXT_BY_MIME = new Map<string, string>(Object.entries({
|
|
83
84
|
'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/mpeg': 'mp3', 'audio/mp4': 'm4a',
|
|
84
85
|
'audio/flac': 'flac', 'audio/midi': 'mid', 'audio/ogg': 'ogg',
|
|
85
86
|
'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/webp': 'webp',
|
|
86
87
|
'video/mp4': 'mp4', 'application/json': 'json', 'text/plain': 'txt',
|
|
87
88
|
'application/pdf': 'pdf', 'application/zip': 'zip',
|
|
88
|
-
};
|
|
89
|
+
}));
|
|
89
90
|
|
|
90
91
|
/** ws→http / wss→https with default ports elided, for origin comparison. */
|
|
91
92
|
export function normalizedOrigin(url: string): string | null {
|
|
@@ -111,9 +112,11 @@ export class ReferenceFetcher {
|
|
|
111
112
|
private readonly saveFile: (fileName: string, data: Buffer, mimeType: string) => Promise<string | null>,
|
|
112
113
|
) {}
|
|
113
114
|
|
|
114
|
-
/** Would the eager (autofetch) default fetch this record?
|
|
115
|
+
/** Would the eager (autofetch) default fetch this record? Not one the
|
|
116
|
+
* fetcher refuses as expired, which would only take an eager slot. */
|
|
115
117
|
isEagerEligible(record: ReferenceRecord): boolean {
|
|
116
118
|
if (record.fetchedPath) return false;
|
|
119
|
+
if (isReferenceExpired(record.testimony)) return false;
|
|
117
120
|
if (record.serverId === undefined) return false;
|
|
118
121
|
const binding = this.resolveServer(record.serverId);
|
|
119
122
|
if (!binding) return false;
|
|
@@ -161,7 +164,8 @@ export class ReferenceFetcher {
|
|
|
161
164
|
|
|
162
165
|
const refusal = this.originCheck(record, binding);
|
|
163
166
|
if (refusal) return { ok: false, error: refusal };
|
|
164
|
-
|
|
167
|
+
const expiry = referenceExpiry(t);
|
|
168
|
+
if (expiry) return { ok: false, error: `reference ${EXPIRY_REASON[expiry]}` };
|
|
165
169
|
|
|
166
170
|
const ceiling = opts?.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
|
|
167
171
|
// Vector 4: a claim already over the ceiling refuses without dialing.
|
|
@@ -215,9 +219,15 @@ export class ReferenceFetcher {
|
|
|
215
219
|
digestVerified = true;
|
|
216
220
|
}
|
|
217
221
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
// The response's header and the server's claimed type are both the
|
|
223
|
+
// server's words, and either can become fetch_reference's mime_type,
|
|
224
|
+
// the saved type and the record's verified type, so each is shown as a
|
|
225
|
+
// label is. One left empty once sanitized counts as absent.
|
|
226
|
+
const header = resp.headers.get('content-type');
|
|
227
|
+
const headerMime = header !== null ? sanitizeLabel(header.split(';')[0], REFERENCE_LIMITS.mimeType) || null : null;
|
|
228
|
+
const claimedMime = t.mimeType !== undefined ? sanitizeLabel(t.mimeType, REFERENCE_LIMITS.mimeType) || undefined : undefined;
|
|
229
|
+
const observedMime = sniffMime(data) ?? headerMime ?? claimedMime ?? 'application/octet-stream';
|
|
230
|
+
const ext = EXT_BY_MIME.get(observedMime) ?? EXT_BY_MIME.get(headerMime ?? '') ?? EXT_BY_MIME.get(claimedMime ?? '') ?? 'bin';
|
|
221
231
|
// §7.3: host-generated storage name; the server's `name` is display-only.
|
|
222
232
|
const path = await this.saveFile(`${record.refId}.${ext}`, data, observedMime);
|
|
223
233
|
if (path === null) return { ok: false, error: 'no writable workspace mount to store the payload' };
|
package/src/mcpl/references.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
* - `disposition:"never"` withholds payload AND uri from model context
|
|
14
14
|
* unconditionally — enforced in code here, not by caller discipline (§5);
|
|
15
15
|
* - stub size is independent of every server-supplied field length (§5);
|
|
16
|
-
* - unparseable `expiresAt` fails closed (§7.4)
|
|
16
|
+
* - unparseable `expiresAt` fails closed (§7.4), and so does one rejected
|
|
17
|
+
* as a field: it's absent as testimony, never read as immortal;
|
|
17
18
|
* - unknown block types never propagate `undefined` (the pre-existing
|
|
18
19
|
* convertBlock switches had no default — a latent corruption this module
|
|
19
20
|
* retires).
|
|
@@ -141,16 +142,42 @@ export function isInlineContradiction(block: unknown): boolean {
|
|
|
141
142
|
export const INLINE_WITHHELD_TEXT =
|
|
142
143
|
'[inline content withheld: nonconformant bulk disposition on inline data]';
|
|
143
144
|
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Why a reference counts as expired, or null when it doesn't: its expiresAt
|
|
147
|
+
* has passed, doesn't parse, or was rejected as a field (§7.4, §8). A
|
|
148
|
+
* rejected expiry (past its schema maximum, or not a string, null included)
|
|
149
|
+
* is absent as testimony under the one invalid-field rule, but reading that
|
|
150
|
+
* absence as "never expires" would let field invalidity widen exposure:
|
|
151
|
+
* like an unparseable one, it fails closed. A reference that claims no
|
|
152
|
+
* expiry has none.
|
|
153
|
+
*/
|
|
154
|
+
export function referenceExpiry(
|
|
155
|
+
t: Pick<ReferenceTestimony, 'expiresAt' | 'rejectedFields'>, nowMs = Date.now(),
|
|
156
|
+
): 'rejected' | 'unparseable' | 'passed' | null {
|
|
157
|
+
if (t.rejectedFields.includes('expiresAt')) return 'rejected';
|
|
158
|
+
if (t.expiresAt === undefined) return null;
|
|
146
159
|
const exp = Date.parse(t.expiresAt);
|
|
147
|
-
|
|
160
|
+
if (Number.isNaN(exp)) return 'unparseable';
|
|
161
|
+
return exp <= nowMs ? 'passed' : null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function isReferenceExpired(t: Pick<ReferenceTestimony, 'expiresAt' | 'rejectedFields'>, nowMs = Date.now()): boolean {
|
|
165
|
+
return referenceExpiry(t, nowMs) !== null;
|
|
148
166
|
}
|
|
149
167
|
|
|
150
|
-
/**
|
|
168
|
+
/** Each expiry case as the stub and the fetcher's refusal both say it. */
|
|
169
|
+
export const EXPIRY_REASON = {
|
|
170
|
+
passed: 'expired: its expiresAt has passed',
|
|
171
|
+
unparseable: 'treated as expired: its expiresAt does not parse (fail closed)',
|
|
172
|
+
rejected: `treated as expired: its expiresAt was rejected as a field, over ${REFERENCE_LIMITS.expiresAt} characters or not a string (fail closed)`,
|
|
173
|
+
} as const;
|
|
174
|
+
|
|
175
|
+
/** Strip control characters (C0, DEL, C1) and every bidi control (marks,
|
|
176
|
+
* embeddings, overrides, isolates, and the Arabic letter mark), trim the
|
|
177
|
+
* whitespace (a BOM included) left at either end, and bound the length.
|
|
151
178
|
* Labels are labels — storage naming is host-generated elsewhere (§7.3). */
|
|
152
179
|
export function sanitizeLabel(s: string, maxChars: number): string {
|
|
153
|
-
const cleaned = s.replace(/[\
|
|
180
|
+
const cleaned = s.replace(/[\p{Cc}\p{Bidi_Control}]/gu, '').trim();
|
|
154
181
|
if (cleaned.length <= maxChars) return cleaned;
|
|
155
182
|
let cut = cleaned.slice(0, Math.max(0, maxChars - 1));
|
|
156
183
|
// never split a surrogate pair at the truncation boundary
|
|
@@ -187,9 +214,10 @@ const MAX_RECORDS = 2000;
|
|
|
187
214
|
/**
|
|
188
215
|
* Host-private store of received references. Raw URIs live here and in stubs
|
|
189
216
|
* never (under `never`) or by policy only (`ref`/absent). Ids are stable and
|
|
190
|
-
* never reused within the process (vector 20)
|
|
191
|
-
*
|
|
192
|
-
*
|
|
217
|
+
* never reused within the process (vector 20). Past MAX_RECORDS the record
|
|
218
|
+
* registered least recently is evicted (registering a uri the registry holds
|
|
219
|
+
* makes its record the newest), and a stale lookup returns undefined — a
|
|
220
|
+
* defined miss, never a different record.
|
|
193
221
|
*
|
|
194
222
|
* Process-wide singleton by design: ids are host-local *names*, not
|
|
195
223
|
* capabilities, and the record itself carries serverId for any future
|
|
@@ -222,6 +250,9 @@ export class ReferenceRegistry {
|
|
|
222
250
|
// describes verified bytes on disk and keeps the testimony they were
|
|
223
251
|
// verified against.
|
|
224
252
|
if (!existing.fetchedPath) existing.testimony = testimony;
|
|
253
|
+
// Newest again, so a uri just mentioned is the last to be evicted.
|
|
254
|
+
this.records.delete(existingId);
|
|
255
|
+
this.records.set(existingId, existing);
|
|
225
256
|
return existing;
|
|
226
257
|
}
|
|
227
258
|
}
|
|
@@ -257,6 +288,12 @@ export const referenceRegistry = new ReferenceRegistry();
|
|
|
257
288
|
// Stub building (§5)
|
|
258
289
|
// ============================================================================
|
|
259
290
|
|
|
291
|
+
/** A reference's type as its stub shows one, or '' when nothing is left of
|
|
292
|
+
* it once sanitized (the stub then leaves it out). */
|
|
293
|
+
export function referenceTypeLabel(mimeType: string | undefined): string {
|
|
294
|
+
return mimeType ? sanitizeLabel(mimeType, STUB_FIELD_CHARS) : '';
|
|
295
|
+
}
|
|
296
|
+
|
|
260
297
|
/**
|
|
261
298
|
* Build the model-visible stub for a reference. Output length is bounded by
|
|
262
299
|
* host constants and independent of every server-supplied field length.
|
|
@@ -267,18 +304,25 @@ export const referenceRegistry = new ReferenceRegistry();
|
|
|
267
304
|
export function buildReferenceStub(t: ReferenceTestimony, provenance?: string, serverId?: string): string {
|
|
268
305
|
const record = referenceRegistry.register(t, serverId);
|
|
269
306
|
const parts: string[] = [];
|
|
270
|
-
|
|
307
|
+
// A label is shown only when something is left of it once sanitized.
|
|
308
|
+
const name = t.name ? sanitizeLabel(t.name, STUB_FIELD_CHARS) : '';
|
|
309
|
+
if (name) parts.push(name);
|
|
271
310
|
const meta: string[] = [];
|
|
272
|
-
|
|
311
|
+
const mimeType = referenceTypeLabel(t.mimeType);
|
|
312
|
+
if (mimeType) meta.push(mimeType);
|
|
273
313
|
if (t.sizeBytes !== undefined) meta.push(`${formatSizeBytes(t.sizeBytes)} claimed`);
|
|
274
314
|
if (meta.length) parts.push(meta.join(', '));
|
|
275
|
-
|
|
315
|
+
const origin = provenance ? sanitizeLabel(provenance, STUB_FIELD_CHARS) : '';
|
|
316
|
+
if (origin) parts.push(origin);
|
|
276
317
|
if (record.fetchedPath) {
|
|
277
318
|
// Already materialized by the host-mediated fetcher: hand the model the
|
|
278
319
|
// workspace path (host-generated, §7.3) so existing tools just work.
|
|
279
320
|
parts.push(`saved: ${sanitizeLabel(record.fetchedPath, 256)}`);
|
|
280
321
|
} else {
|
|
281
|
-
|
|
322
|
+
// An expired reference says so rather than inviting a fetch_reference
|
|
323
|
+
// call the fetcher can only refuse.
|
|
324
|
+
const expiry = referenceExpiry(record.testimony);
|
|
325
|
+
parts.push(expiry ? EXPIRY_REASON[expiry] : 'fetch with fetch_reference');
|
|
282
326
|
}
|
|
283
327
|
return `[${record.refId}] ${parts.join(' — ') || 'referenced content'}`;
|
|
284
328
|
}
|
|
@@ -48,6 +48,28 @@ import type {
|
|
|
48
48
|
import { McplMethod } from './types.js';
|
|
49
49
|
import type { ToolLifecycleParams, ToolObserveRule } from './tool-lifecycle.js';
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Payload of the connection `'close'` event (third argument; the first two
|
|
53
|
+
* stay `(code, signal)` for existing listeners). Lets a host see WHY the
|
|
54
|
+
* link went down — in particular a WebSocket close code such as 4001 from
|
|
55
|
+
* a server that refused or expired the credential — so it can refresh what
|
|
56
|
+
* `accessProvider` will hand the next dial instead of replaying it.
|
|
57
|
+
*/
|
|
58
|
+
export interface McplConnectionCloseInfo {
|
|
59
|
+
/** Transport kind that closed; null when the connection never had one
|
|
60
|
+
* (a disconnected stub torn down before its first reconnect). */
|
|
61
|
+
transport: McplTransport['kind'] | null;
|
|
62
|
+
/** WebSocket close code (e.g. 4001) or stdio child exit code; null when unknown. */
|
|
63
|
+
code: number | null;
|
|
64
|
+
/** Signal that ended a stdio child; null otherwise. */
|
|
65
|
+
signal: NodeJS.Signals | null;
|
|
66
|
+
/** WebSocket close reason, or a short description of the cause. */
|
|
67
|
+
reason?: string;
|
|
68
|
+
/** True when the host closed the link (`close()` / `reconnectAfterFailure()`),
|
|
69
|
+
* false when the transport dropped unexpectedly. */
|
|
70
|
+
byHost: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
51
73
|
/** Timeout for the initialize handshake in milliseconds.
|
|
52
74
|
* Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
|
|
53
75
|
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
@@ -93,7 +115,7 @@ interface RequestOptions {
|
|
|
93
115
|
* - `'channels-changed'` — Server sent `channels/changed`
|
|
94
116
|
* - `'channels-incoming'` — Server sent `channels/incoming`
|
|
95
117
|
* - `'error'` — Connection-level error
|
|
96
|
-
* - `'close'` — Connection closed
|
|
118
|
+
* - `'close'` — Connection closed: `(code, signal, info: McplConnectionCloseInfo)`
|
|
97
119
|
* - `'connect-failed'` — Initial connect failed; background retry scheduled
|
|
98
120
|
* - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
|
|
99
121
|
* - `'reconnect'` — Background reconnect succeeded
|
|
@@ -209,6 +231,9 @@ export class McplServerConnection extends EventEmitter {
|
|
|
209
231
|
* observable even though its original promise has already been rejected. */
|
|
210
232
|
private orphanedRequests = new Map<string | number, { method: string; renderParams?: Record<string, unknown> }>();
|
|
211
233
|
private closed = false;
|
|
234
|
+
/** Set by `reconnectAfterFailure()` before it closes a live transport, so
|
|
235
|
+
* the transport close handler reports that teardown as `byHost: true`. */
|
|
236
|
+
private hostCloseRequested = false;
|
|
212
237
|
private closePromise: Promise<void> | null = null;
|
|
213
238
|
private closeIncomplete = false;
|
|
214
239
|
private reconnectPromise: Promise<void> | null = null;
|
|
@@ -864,7 +889,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
864
889
|
if (this.reconnectPromise) await this.reconnectPromise;
|
|
865
890
|
if (this.transport) await this.transport.close();
|
|
866
891
|
this.closeIncomplete = false;
|
|
867
|
-
this.emit('close');
|
|
892
|
+
this.emit('close', null, null, this.closeInfoByHost());
|
|
868
893
|
});
|
|
869
894
|
this.closePromise = attempt;
|
|
870
895
|
void attempt.catch(() => {
|
|
@@ -886,14 +911,26 @@ export class McplServerConnection extends EventEmitter {
|
|
|
886
911
|
return;
|
|
887
912
|
}
|
|
888
913
|
if (this.transport) {
|
|
914
|
+
this.hostCloseRequested = true;
|
|
889
915
|
await this.transport.close();
|
|
890
916
|
return;
|
|
891
917
|
}
|
|
892
918
|
this.closed = true;
|
|
893
|
-
this.emit('close');
|
|
919
|
+
this.emit('close', null, null, this.closeInfoByHost());
|
|
894
920
|
this.scheduleReconnect();
|
|
895
921
|
}
|
|
896
922
|
|
|
923
|
+
/** 'close' payload for a host-initiated teardown (no transport close code). */
|
|
924
|
+
private closeInfoByHost(): McplConnectionCloseInfo {
|
|
925
|
+
return {
|
|
926
|
+
transport: this.transport?.kind ?? null,
|
|
927
|
+
code: null,
|
|
928
|
+
signal: null,
|
|
929
|
+
reason: 'closed by host',
|
|
930
|
+
byHost: true,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
897
934
|
/**
|
|
898
935
|
* Schedule a background reconnection attempt.
|
|
899
936
|
*
|
|
@@ -1285,6 +1322,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1285
1322
|
* and — when enabled — schedules a background reconnect.
|
|
1286
1323
|
*/
|
|
1287
1324
|
private setupLifecycle(transport: McplTransport): void {
|
|
1325
|
+
this.hostCloseRequested = false;
|
|
1288
1326
|
transport.on('error', (err: Error) => {
|
|
1289
1327
|
this.emit('error', new Error(`MCPL server "${this.id}" transport error: ${err.message}`));
|
|
1290
1328
|
});
|
|
@@ -1306,7 +1344,20 @@ export class McplServerConnection extends EventEmitter {
|
|
|
1306
1344
|
this.pendingRequests.clear();
|
|
1307
1345
|
this.orphanedRequests.clear();
|
|
1308
1346
|
|
|
1309
|
-
|
|
1347
|
+
// Forward the transport's close info so listeners can tell a server
|
|
1348
|
+
// that refused the credential (WebSocket 4001) from a crash, and act
|
|
1349
|
+
// before the reconnect below redials. A teardown the host asked for
|
|
1350
|
+
// via reconnectAfterFailure() arrives here too; report it as such.
|
|
1351
|
+
const byHost = this.hostCloseRequested;
|
|
1352
|
+
this.hostCloseRequested = false;
|
|
1353
|
+
const closeInfo: McplConnectionCloseInfo = {
|
|
1354
|
+
transport: transport.kind,
|
|
1355
|
+
code: info.code ?? null,
|
|
1356
|
+
signal: info.signal ?? null,
|
|
1357
|
+
reason: info.reason ?? (byHost ? 'closed by host' : undefined),
|
|
1358
|
+
byHost,
|
|
1359
|
+
};
|
|
1360
|
+
this.emit('close', closeInfo.code, closeInfo.signal, closeInfo);
|
|
1310
1361
|
|
|
1311
1362
|
// Schedule reconnect if enabled (unexpected disconnect triggers auto-reconnect)
|
|
1312
1363
|
if (this.reconnectEnabled) {
|
package/src/module-registry.ts
CHANGED
|
@@ -26,6 +26,12 @@ import type {
|
|
|
26
26
|
import type { Agent } from './agent.js';
|
|
27
27
|
import { DEFAULT_SHUTDOWN_TIMEOUT_MS, validateShutdownTimeout, waitForShutdown } from './shutdown.js';
|
|
28
28
|
|
|
29
|
+
/** How the registry queues a module's event: its push names the module. A
|
|
30
|
+
* `ProcessQueue` serves too, ignoring the name. */
|
|
31
|
+
export interface ModuleQueue {
|
|
32
|
+
push(event: ProcessEvent, moduleName?: string): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
29
35
|
const MODULE_STATE_PREFIX = 'modules/';
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -78,7 +84,7 @@ export class ModuleRegistry {
|
|
|
78
84
|
private moduleContexts: Map<string, ModuleContextImpl> = new Map();
|
|
79
85
|
private speechHandlers: SpeechHandler[] = [];
|
|
80
86
|
private store: JsStore;
|
|
81
|
-
private queue:
|
|
87
|
+
private queue: ModuleQueue;
|
|
82
88
|
private getAgents: () => Agent[];
|
|
83
89
|
private addMessageFn: (participant: string, content: ContentBlock[], metadata?: MessageMetadata) => MessageId;
|
|
84
90
|
private editMessageFn: (id: MessageId, content: ContentBlock[]) => void;
|
|
@@ -86,7 +92,7 @@ export class ModuleRegistry {
|
|
|
86
92
|
private getMessageFn: (id: MessageId) => StoredMessage | null;
|
|
87
93
|
private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
|
|
88
94
|
|
|
89
|
-
private pushEventFn: (event: ProcessEvent) => void;
|
|
95
|
+
private pushEventFn: (event: ProcessEvent, moduleName?: string) => void;
|
|
90
96
|
private onTraceFn: (listener: TraceEventListener) => () => void;
|
|
91
97
|
private callToolFn: (call: ToolCall) => Promise<ToolResult>;
|
|
92
98
|
/** Framework ops-alert channel, surfaced to modules via
|
|
@@ -101,7 +107,7 @@ export class ModuleRegistry {
|
|
|
101
107
|
|
|
102
108
|
constructor(
|
|
103
109
|
store: JsStore,
|
|
104
|
-
queue:
|
|
110
|
+
queue: ModuleQueue,
|
|
105
111
|
options: {
|
|
106
112
|
getAgents: () => Agent[];
|
|
107
113
|
addMessage: (participant: string, content: ContentBlock[], metadata?: MessageMetadata) => MessageId;
|
|
@@ -109,7 +115,8 @@ export class ModuleRegistry {
|
|
|
109
115
|
removeMessage: (id: MessageId) => void;
|
|
110
116
|
getMessage: (id: MessageId) => StoredMessage | null;
|
|
111
117
|
queryMessages: (filter: MessageQuery) => MessageQueryResult;
|
|
112
|
-
|
|
118
|
+
/** Called with the pushing module's name. */
|
|
119
|
+
pushEvent: (event: ProcessEvent, moduleName?: string) => void;
|
|
113
120
|
onTrace: (listener: TraceEventListener) => () => void;
|
|
114
121
|
callTool: (call: ToolCall) => Promise<ToolResult>;
|
|
115
122
|
notifyOps?: (kind: string, agentName: string, message: string, data?: Record<string, unknown>) => void;
|
|
@@ -537,7 +544,7 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
537
544
|
private removeMessageFn: (id: MessageId) => void;
|
|
538
545
|
private getMessageFn: (id: MessageId) => StoredMessage | null;
|
|
539
546
|
private queryMessagesFn: (filter: MessageQuery) => MessageQueryResult;
|
|
540
|
-
private pushEventFn: (event: ProcessEvent) => void;
|
|
547
|
+
private pushEventFn: (event: ProcessEvent, moduleName?: string) => void;
|
|
541
548
|
private onTraceFn: (listener: TraceEventListener) => () => void;
|
|
542
549
|
private callToolFn: (call: ToolCall) => Promise<ToolResult>;
|
|
543
550
|
|
|
@@ -548,7 +555,7 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
548
555
|
moduleName: string,
|
|
549
556
|
store: JsStore,
|
|
550
557
|
stateId: string,
|
|
551
|
-
queue:
|
|
558
|
+
queue: ModuleQueue,
|
|
552
559
|
registry: ModuleRegistry,
|
|
553
560
|
isRestart: boolean,
|
|
554
561
|
getAgents: () => Agent[],
|
|
@@ -557,14 +564,15 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
557
564
|
removeMessage: (id: MessageId) => void,
|
|
558
565
|
getMessage: (id: MessageId) => StoredMessage | null,
|
|
559
566
|
queryMessages: (filter: MessageQuery) => MessageQueryResult,
|
|
560
|
-
pushEvent: (event: ProcessEvent) => void,
|
|
567
|
+
pushEvent: (event: ProcessEvent, moduleName?: string) => void,
|
|
561
568
|
onTrace: (listener: TraceEventListener) => () => void,
|
|
562
569
|
callTool: (call: ToolCall) => Promise<ToolResult>
|
|
563
570
|
) {
|
|
564
571
|
this.moduleName = moduleName;
|
|
565
572
|
this.store = store;
|
|
566
573
|
this.stateId = stateId;
|
|
567
|
-
this
|
|
574
|
+
// The deprecated handle: a push names this module, as pushEvent() does.
|
|
575
|
+
this.queue = { push: (event) => queue.push(event, moduleName) };
|
|
568
576
|
this.registry = registry;
|
|
569
577
|
this.isRestart = isRestart;
|
|
570
578
|
this.getAgentsFn = getAgents;
|
|
@@ -747,7 +755,7 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
747
755
|
}
|
|
748
756
|
|
|
749
757
|
pushEvent(event: ProcessEvent): void {
|
|
750
|
-
this.pushEventFn(event);
|
|
758
|
+
this.pushEventFn(event, this.moduleName);
|
|
751
759
|
}
|
|
752
760
|
|
|
753
761
|
private persistExternalIdMap(): void {
|
|
@@ -39,7 +39,7 @@ import { MountWatcher, type FsChange } from './watcher.js';
|
|
|
39
39
|
import { hashContent, DEFAULT_MAX_FILE_SIZE } from './sync.js';
|
|
40
40
|
import { DiskAgreement, sameRootIdentity } from './disk-agreement.js';
|
|
41
41
|
import { BranchIntents } from './branch-intent.js';
|
|
42
|
-
import { filesystemTrustsCtime, locateRoot } from './observe.js';
|
|
42
|
+
import { coveredByIgnore, filesystemTrustsCtime, locateRoot } from './observe.js';
|
|
43
43
|
import { TOOL_BATCH_HOOK_TIMEOUT_MS } from '../../module-registry.js';
|
|
44
44
|
import {
|
|
45
45
|
type MountRuntime,
|
|
@@ -1220,7 +1220,14 @@ export class WorkspaceModule implements Module {
|
|
|
1220
1220
|
+ 'A mount whose root is not the directory disk last agreed with (an unmounted drive, a replaced '
|
|
1221
1221
|
+ 'directory) is unavailable and listed under `incomplete`. Reconnect the drive; acceptRoot is for a '
|
|
1222
1222
|
+ 'directory replaced on purpose, or a drive back under a new device number, never for one still '
|
|
1223
|
-
+ 'unplugged, whose empty mountpoint would become the root.'
|
|
1223
|
+
+ 'unplugged, whose empty mountpoint would become the root. What the scan passed over by design and '
|
|
1224
|
+
+ 'reported nowhere else is counted under `passedOver`, by mount and reason: entries the mount\'s '
|
|
1225
|
+
+ 'ignore list covers that hold nothing the workspace tracks (an ignored directory counts once). So a '
|
|
1226
|
+
+ 'total of 0, with no conflicts and nothing under `passedOver`, `incomplete` or `skipped`, means the '
|
|
1227
|
+
+ 'sync looked everywhere and nothing had changed. A path the mount ignores is synced when you name '
|
|
1228
|
+
+ 'it, and listed under `ignored`: a sync without a path passes over it. Naming an ignored directory '
|
|
1229
|
+
+ 'sets aside the patterns that cover it or a directory above it; one that covers only something '
|
|
1230
|
+
+ 'inside it still applies, and what it covers is passed over.',
|
|
1224
1231
|
inputSchema: {
|
|
1225
1232
|
type: 'object' as const,
|
|
1226
1233
|
properties: {
|
|
@@ -2313,7 +2320,9 @@ export class WorkspaceModule implements Module {
|
|
|
2313
2320
|
};
|
|
2314
2321
|
}
|
|
2315
2322
|
|
|
2316
|
-
|
|
2323
|
+
// Its whole receipt is what it changed: kept in the stub if the guard
|
|
2324
|
+
// withholds the result (agent-framework #277).
|
|
2325
|
+
return { success: true, data: { path: input.path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
|
|
2317
2326
|
});
|
|
2318
2327
|
}
|
|
2319
2328
|
|
|
@@ -2761,6 +2770,10 @@ export class WorkspaceModule implements Module {
|
|
|
2761
2770
|
: {}),
|
|
2762
2771
|
...(blocked.length > 0 ? { skipped: blocked } : {}),
|
|
2763
2772
|
},
|
|
2773
|
+
// What disk lost, then what it was written: kept in the stub if the
|
|
2774
|
+
// guard withholds the result (agent-framework #277). What was left or
|
|
2775
|
+
// skipped, and why, is in the full result.
|
|
2776
|
+
keepWhenWithheld: ['deleted', 'materialized'],
|
|
2764
2777
|
};
|
|
2765
2778
|
}
|
|
2766
2779
|
|
|
@@ -2811,6 +2824,8 @@ export class WorkspaceModule implements Module {
|
|
|
2811
2824
|
}> = [];
|
|
2812
2825
|
const allSkipped: Array<{ mount: string; path: string; reason: string }> = [];
|
|
2813
2826
|
const allIncomplete: Array<{ mount: string; path: string; reason: string }> = [];
|
|
2827
|
+
const allPassedOver: Array<{ mount: string; reason: string; count: number }> = [];
|
|
2828
|
+
const allIgnored: Array<{ mount: string; path: string; note: string }> = [];
|
|
2814
2829
|
const rootsAccepted: string[] = [];
|
|
2815
2830
|
if (input.acceptRoot && input.path) {
|
|
2816
2831
|
return { success: false, error: 'acceptRoot takes a whole mount\'s root: give mount, or nothing, not path', isError: true };
|
|
@@ -2845,7 +2860,9 @@ export class WorkspaceModule implements Module {
|
|
|
2845
2860
|
store.treeList(mount.treeStateId, prefix).length > 0 ||
|
|
2846
2861
|
this.agreementOrThrow().paths(name, relativePath).some(([p]) => p.startsWith(prefix)) ||
|
|
2847
2862
|
this.intents.get(name)!.list(relativePath).some(([p]) => p.startsWith(prefix));
|
|
2848
|
-
|
|
2863
|
+
// named: ignore patterns covering the path asked for don't apply
|
|
2864
|
+
// beneath it (a directory scope's walk; a path scope doesn't walk).
|
|
2865
|
+
const opts: PassOptions = { rehash: true, adopt: () => true, branchId: askedOn, named: true };
|
|
2849
2866
|
if (!isDir) return this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
|
|
2850
2867
|
const beneath = await this.passUnlocked(mount, { kind: 'dir', dir: relativePath, recursive: true }, opts);
|
|
2851
2868
|
// Something at the path itself is no part of a directory scope: a file
|
|
@@ -2860,12 +2877,15 @@ export class WorkspaceModule implements Module {
|
|
|
2860
2877
|
|| this.intents.get(name)!.get(relativePath) !== null;
|
|
2861
2878
|
if (!atPath) return beneath;
|
|
2862
2879
|
const itself = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, opts);
|
|
2880
|
+
const passedOver = new Map(beneath.passedOver);
|
|
2881
|
+
for (const [reason, count] of itself.passedOver) passedOver.set(reason, (passedOver.get(reason) ?? 0) + count);
|
|
2863
2882
|
return {
|
|
2864
2883
|
ops: [...beneath.ops, ...itself.ops],
|
|
2865
2884
|
discarded: [...beneath.discarded, ...itself.discarded],
|
|
2866
2885
|
newConflicts: [...beneath.newConflicts, ...itself.newConflicts],
|
|
2867
2886
|
reports: new Map([...beneath.reports, ...itself.reports]),
|
|
2868
2887
|
incomplete: [...beneath.incomplete, ...itself.incomplete],
|
|
2888
|
+
passedOver,
|
|
2869
2889
|
dirs: beneath.dirs,
|
|
2870
2890
|
branchId: itself.branchId ?? beneath.branchId,
|
|
2871
2891
|
};
|
|
@@ -2892,6 +2912,17 @@ export class WorkspaceModule implements Module {
|
|
|
2892
2912
|
}
|
|
2893
2913
|
}
|
|
2894
2914
|
for (const region of pass.incomplete) allIncomplete.push({ mount: name, ...region });
|
|
2915
|
+
for (const [reason, count] of pass.passedOver) allPassedOver.push({ mount: name, reason, count });
|
|
2916
|
+
// A path named explicitly is synced even where the mount's ignore list
|
|
2917
|
+
// covers it, and the result says so: a sync without a path passes
|
|
2918
|
+
// over it, so nothing else keeps it current (agent-framework #276).
|
|
2919
|
+
if (coveredByIgnore(relativePath, mount.config.ignore ?? [])) {
|
|
2920
|
+
allIgnored.push({
|
|
2921
|
+
mount: name,
|
|
2922
|
+
path: relativePath,
|
|
2923
|
+
note: 'this mount\'s ignore list covers it, so a sync without a path won\'t maintain it',
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2895
2926
|
}
|
|
2896
2927
|
|
|
2897
2928
|
return {
|
|
@@ -2902,8 +2933,17 @@ export class WorkspaceModule implements Module {
|
|
|
2902
2933
|
totalConflicts: allResults.reduce((sum, r) => sum + r.conflicts.length, 0),
|
|
2903
2934
|
...(allSkipped.length > 0 ? { skipped: allSkipped } : {}),
|
|
2904
2935
|
...(allIncomplete.length > 0 ? { incomplete: allIncomplete } : {}),
|
|
2936
|
+
...(allPassedOver.length > 0 ? { passedOver: allPassedOver } : {}),
|
|
2937
|
+
...(allIgnored.length > 0 ? { ignored: allIgnored } : {}),
|
|
2905
2938
|
...(rootsAccepted.length > 0 ? { rootsAccepted } : {}),
|
|
2906
2939
|
},
|
|
2940
|
+
// What the workspace took from disk, gave up, or now holds as a
|
|
2941
|
+
// conflict, per mount, and any root it accepted: kept in the stub if
|
|
2942
|
+
// the guard withholds the result (agent-framework #277). What the scan
|
|
2943
|
+
// skipped or couldn't see is in the full result.
|
|
2944
|
+
// A root it accepted is short and telling: first, so a long results
|
|
2945
|
+
// list can't cut it away.
|
|
2946
|
+
keepWhenWithheld: ['rootsAccepted', 'results'],
|
|
2907
2947
|
};
|
|
2908
2948
|
}
|
|
2909
2949
|
|
|
@@ -494,7 +494,9 @@ export async function observePath(view: MountView, rootReal: string, relativePat
|
|
|
494
494
|
return { kind: 'unobserved', reason: `a symlink that cannot be resolved (${errno(err) ?? String(err)})` };
|
|
495
495
|
}
|
|
496
496
|
if (!contained(rootReal, real)) return { kind: 'unobserved', reason: 'a symlink that leaves the mount' };
|
|
497
|
-
if (!target.isFile())
|
|
497
|
+
if (!target.isFile()) {
|
|
498
|
+
return { kind: 'unobserved', reason: target.isDirectory() ? 'a symlink to a directory, not walked' : 'not a regular file' };
|
|
499
|
+
}
|
|
498
500
|
info = target;
|
|
499
501
|
} else if (info.isDirectory()) {
|
|
500
502
|
return { kind: 'unobserved', reason: 'a directory' };
|
|
@@ -682,12 +684,27 @@ export interface Walk {
|
|
|
682
684
|
missing: Set<string>;
|
|
683
685
|
/**
|
|
684
686
|
* Where the walk didn't reach, and why. `error` and `cap` regions may hide
|
|
685
|
-
* files the listing would show
|
|
686
|
-
*
|
|
687
|
+
* files the listing would show. An `ignored` region is passed over by
|
|
688
|
+
* design: one that hides a tracked entry is reported as incomplete, and
|
|
689
|
+
* one that hides nothing is counted as passed over. A `symlink` region is
|
|
690
|
+
* never walked; the pass reports the link itself by path.
|
|
687
691
|
*/
|
|
688
692
|
incomplete: Array<{ path: string; reason: string; kind: 'error' | 'cap' | 'ignored' | 'symlink' }>;
|
|
689
693
|
}
|
|
690
694
|
|
|
695
|
+
/**
|
|
696
|
+
* Whether a mount's ignore list covers a mount-relative path: the path itself
|
|
697
|
+
* or any directory above it, as the walk's own test reads each entry by its
|
|
698
|
+
* path and name.
|
|
699
|
+
*/
|
|
700
|
+
export function coveredByIgnore(relativePath: string, patterns: string[]): boolean {
|
|
701
|
+
const segments = relativePath.split('/').filter(Boolean);
|
|
702
|
+
for (let i = 0; i < segments.length; i++) {
|
|
703
|
+
if (isIgnored(segments.slice(0, i + 1).join('/'), segments[i]!, patterns)) return true;
|
|
704
|
+
}
|
|
705
|
+
return false;
|
|
706
|
+
}
|
|
707
|
+
|
|
691
708
|
/** Simple ignore patterns, as the watcher and the old walk read them. */
|
|
692
709
|
export function isIgnored(relativePath: string, name: string, patterns: string[]): boolean {
|
|
693
710
|
for (const pattern of patterns) {
|
|
@@ -729,7 +746,10 @@ async function checkChild(view: MountView, rootReal: string, parent: CheckedDir,
|
|
|
729
746
|
* the directory at its path: a directory moved or replaced meanwhile is an
|
|
730
747
|
* incomplete region, contributing no names and proving nothing absent.
|
|
731
748
|
*/
|
|
732
|
-
export async function walkScope(
|
|
749
|
+
export async function walkScope(
|
|
750
|
+
view: MountView, rootReal: string, scope: string, recursive: boolean, cap: number,
|
|
751
|
+
opts: { named?: boolean } = {},
|
|
752
|
+
): Promise<Walk> {
|
|
733
753
|
const walk: Walk = { files: new Set(), dirs: new Set(), others: new Map(), complete: new Set(), missing: new Set(), incomplete: [] };
|
|
734
754
|
const top = await checkDir(view, rootReal, scope);
|
|
735
755
|
if (top.kind === 'absent') {
|
|
@@ -741,6 +761,12 @@ export async function walkScope(view: MountView, rootReal: string, scope: string
|
|
|
741
761
|
return walk;
|
|
742
762
|
}
|
|
743
763
|
let capped = false;
|
|
764
|
+
// A scope a path sync names is walked even where the ignore list covers
|
|
765
|
+
// it: a pattern covering the scope itself, or a directory above it, doesn't
|
|
766
|
+
// apply beneath it. So a named directory's contents are synced, as a named
|
|
767
|
+
// file is (agent-framework #276). A listing keeps the ignore: its pass
|
|
768
|
+
// would otherwise start tracking what it lists.
|
|
769
|
+
const ignore = opts.named ? view.ignore.filter((pattern) => !coveredByIgnore(scope, [pattern])) : view.ignore;
|
|
744
770
|
|
|
745
771
|
const visit = async (dir: CheckedDir): Promise<void> => {
|
|
746
772
|
const rel = dir.rel;
|
|
@@ -770,7 +796,7 @@ export async function walkScope(view: MountView, rootReal: string, scope: string
|
|
|
770
796
|
let reachedCap = false;
|
|
771
797
|
for (const entry of entries) {
|
|
772
798
|
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
773
|
-
if (isIgnored(childRel, entry.name,
|
|
799
|
+
if (isIgnored(childRel, entry.name, ignore)) {
|
|
774
800
|
others.push([childRel, 'ignored by the mount']);
|
|
775
801
|
regions.push({ path: childRel, reason: 'ignored by the mount', kind: 'ignored' });
|
|
776
802
|
continue;
|