@animalabs/agent-framework 0.26.2 → 0.27.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 +56 -6
- package/dist/src/agent.d.ts +263 -22
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +531 -54
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/server.d.ts.map +1 -1
- package/dist/src/api/server.js +1 -2
- package/dist/src/api/server.js.map +1 -1
- package/dist/src/auto-continue.d.ts +33 -0
- package/dist/src/auto-continue.d.ts.map +1 -0
- package/dist/src/auto-continue.js +35 -0
- package/dist/src/auto-continue.js.map +1 -0
- package/dist/src/code-execution/isolate/scripts.d.ts +11 -0
- package/dist/src/code-execution/isolate/scripts.d.ts.map +1 -1
- package/dist/src/code-execution/isolate/scripts.js +5 -1
- package/dist/src/code-execution/isolate/scripts.js.map +1 -1
- package/dist/src/framework.d.ts +759 -129
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +3482 -928
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +309 -46
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +2117 -395
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/index.d.ts +2 -2
- package/dist/src/gate/index.d.ts.map +1 -1
- package/dist/src/gate/index.js +1 -1
- package/dist/src/gate/index.js.map +1 -1
- package/dist/src/gate/recipe-reconcile.d.ts +103 -0
- package/dist/src/gate/recipe-reconcile.d.ts.map +1 -0
- package/dist/src/gate/recipe-reconcile.js +325 -0
- package/dist/src/gate/recipe-reconcile.js.map +1 -0
- package/dist/src/gate/types.d.ts +68 -8
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +6 -4
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +5 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +433 -113
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +1152 -434
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +16 -8
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -1
- package/dist/src/mcpl/conversation-router.js +29 -15
- package/dist/src/mcpl/conversation-router.js.map +1 -1
- package/dist/src/mcpl/convert-block.d.ts.map +1 -1
- package/dist/src/mcpl/convert-block.js +14 -2
- package/dist/src/mcpl/convert-block.js.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts +7 -1
- package/dist/src/mcpl/hook-orchestrator.d.ts.map +1 -1
- package/dist/src/mcpl/hook-orchestrator.js +32 -7
- package/dist/src/mcpl/hook-orchestrator.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +5 -1
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +4 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/inference-router.d.ts +14 -2
- package/dist/src/mcpl/inference-router.d.ts.map +1 -1
- package/dist/src/mcpl/inference-router.js +73 -28
- package/dist/src/mcpl/inference-router.js.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +4 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +6 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/protocol-family.d.ts.map +1 -1
- package/dist/src/mcpl/protocol-family.js +3 -0
- package/dist/src/mcpl/protocol-family.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +20 -2
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -1
- package/dist/src/mcpl/push-coalescer.js +33 -11
- package/dist/src/mcpl/push-coalescer.js.map +1 -1
- package/dist/src/mcpl/reference-fetcher.d.ts +63 -42
- package/dist/src/mcpl/reference-fetcher.d.ts.map +1 -1
- package/dist/src/mcpl/reference-fetcher.js +177 -145
- package/dist/src/mcpl/reference-fetcher.js.map +1 -1
- package/dist/src/mcpl/references.d.ts +380 -46
- package/dist/src/mcpl/references.d.ts.map +1 -1
- package/dist/src/mcpl/references.js +736 -77
- package/dist/src/mcpl/references.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +82 -3
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +132 -7
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.d.ts +11 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -1
- package/dist/src/mcpl/tool-lifecycle.js +21 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -1
- package/dist/src/mcpl/tool-presence.d.ts +259 -0
- package/dist/src/mcpl/tool-presence.d.ts.map +1 -0
- package/dist/src/mcpl/tool-presence.js +628 -0
- package/dist/src/mcpl/tool-presence.js.map +1 -0
- package/dist/src/mcpl/tool-result-normalize.d.ts +9 -3
- package/dist/src/mcpl/tool-result-normalize.d.ts.map +1 -1
- package/dist/src/mcpl/tool-result-normalize.js +14 -6
- package/dist/src/mcpl/tool-result-normalize.js.map +1 -1
- package/dist/src/mcpl/tool-signature.d.ts +39 -0
- package/dist/src/mcpl/tool-signature.d.ts.map +1 -0
- package/dist/src/mcpl/tool-signature.js +292 -0
- package/dist/src/mcpl/tool-signature.js.map +1 -0
- package/dist/src/mcpl/toolset-host.d.ts +200 -0
- package/dist/src/mcpl/toolset-host.d.ts.map +1 -0
- package/dist/src/mcpl/toolset-host.js +621 -0
- package/dist/src/mcpl/toolset-host.js.map +1 -0
- package/dist/src/mcpl/toolsets-module.d.ts +80 -0
- package/dist/src/mcpl/toolsets-module.d.ts.map +1 -0
- package/dist/src/mcpl/toolsets-module.js +431 -0
- package/dist/src/mcpl/toolsets-module.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +2 -2
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +3 -2
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +21 -1
- 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 +7 -9
- package/dist/src/mcpl/visible-content.d.ts.map +1 -1
- package/dist/src/mcpl/visible-content.js +21 -9
- package/dist/src/mcpl/visible-content.js.map +1 -1
- package/dist/src/module-registry.d.ts +23 -8
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +68 -15
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +2 -1
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +51 -17
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +108 -6
- package/dist/src/modules/history/semantic.d.ts.map +1 -1
- package/dist/src/modules/history/semantic.js +273 -57
- package/dist/src/modules/history/semantic.js.map +1 -1
- package/dist/src/modules/workspace/direct-write.d.ts +181 -0
- package/dist/src/modules/workspace/direct-write.d.ts.map +1 -0
- package/dist/src/modules/workspace/direct-write.js +384 -0
- package/dist/src/modules/workspace/direct-write.js.map +1 -0
- package/dist/src/modules/workspace/edit-text.d.ts +26 -12
- package/dist/src/modules/workspace/edit-text.d.ts.map +1 -1
- package/dist/src/modules/workspace/edit-text.js +58 -28
- package/dist/src/modules/workspace/edit-text.js.map +1 -1
- package/dist/src/modules/workspace/effects.d.ts +10 -0
- package/dist/src/modules/workspace/effects.d.ts.map +1 -1
- package/dist/src/modules/workspace/effects.js +1 -1
- package/dist/src/modules/workspace/effects.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +246 -27
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +1216 -236
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/read-page.d.ts +41 -0
- package/dist/src/modules/workspace/read-page.d.ts.map +1 -0
- package/dist/src/modules/workspace/read-page.js +213 -0
- package/dist/src/modules/workspace/read-page.js.map +1 -0
- package/dist/src/modules/workspace/result-text.d.ts +67 -0
- package/dist/src/modules/workspace/result-text.d.ts.map +1 -0
- package/dist/src/modules/workspace/result-text.js +94 -0
- package/dist/src/modules/workspace/result-text.js.map +1 -0
- package/dist/src/modules/workspace/text.d.ts +29 -12
- package/dist/src/modules/workspace/text.d.ts.map +1 -1
- package/dist/src/modules/workspace/text.js +35 -33
- package/dist/src/modules/workspace/text.js.map +1 -1
- package/dist/src/modules/workspace/types.d.ts +41 -17
- package/dist/src/modules/workspace/types.d.ts.map +1 -1
- package/dist/src/modules/workspace/types.js.map +1 -1
- package/dist/src/sent-rows.d.ts +59 -0
- package/dist/src/sent-rows.d.ts.map +1 -0
- package/dist/src/sent-rows.js +145 -0
- package/dist/src/sent-rows.js.map +1 -0
- package/dist/src/silent-heartbeat.d.ts +17 -10
- package/dist/src/silent-heartbeat.d.ts.map +1 -1
- package/dist/src/silent-heartbeat.js.map +1 -1
- package/dist/src/stable-hash.d.ts +3 -0
- package/dist/src/stable-hash.d.ts.map +1 -0
- package/dist/src/stable-hash.js +15 -0
- package/dist/src/stable-hash.js.map +1 -0
- package/dist/src/stored-injections.d.ts +99 -0
- package/dist/src/stored-injections.d.ts.map +1 -0
- package/dist/src/stored-injections.js +106 -0
- package/dist/src/stored-injections.js.map +1 -0
- package/dist/src/text-as-stored.d.ts +4 -3
- package/dist/src/text-as-stored.d.ts.map +1 -1
- package/dist/src/text-as-stored.js +4 -3
- package/dist/src/text-as-stored.js.map +1 -1
- package/dist/src/thinking-drops.d.ts +81 -0
- package/dist/src/thinking-drops.d.ts.map +1 -0
- package/dist/src/thinking-drops.js +103 -0
- package/dist/src/thinking-drops.js.map +1 -0
- package/dist/src/tool-image-ledger.d.ts +9 -8
- package/dist/src/tool-image-ledger.d.ts.map +1 -1
- package/dist/src/tool-image-ledger.js +9 -8
- package/dist/src/tool-image-ledger.js.map +1 -1
- package/dist/src/tool-presentation.d.ts.map +1 -1
- package/dist/src/tool-presentation.js +3 -1
- package/dist/src/tool-presentation.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +4 -2
- package/dist/src/tool-result-guard.d.ts.map +1 -1
- package/dist/src/tool-result-guard.js +21 -6
- package/dist/src/tool-result-guard.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +176 -24
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +411 -91
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/tune-out/coordinator.d.ts +17 -9
- package/dist/src/tune-out/coordinator.d.ts.map +1 -1
- package/dist/src/tune-out/coordinator.js +84 -37
- package/dist/src/tune-out/coordinator.js.map +1 -1
- package/dist/src/types/agent.d.ts +186 -7
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +15 -1
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +24 -3
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +90 -7
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +57 -2
- package/dist/src/types/trace.d.ts.map +1 -1
- package/package.json +4 -3
- package/src/agent.ts +593 -60
- package/src/api/server.ts +1 -2
- package/src/auto-continue.ts +42 -0
- package/src/code-execution/isolate/scripts.ts +11 -3
- package/src/framework.ts +3601 -967
- package/src/gate/event-gate.ts +2315 -389
- package/src/gate/index.ts +2 -1
- package/src/gate/recipe-reconcile.ts +355 -0
- package/src/gate/types.ts +70 -8
- package/src/index.ts +7 -4
- package/src/mcpl/channel-registry.ts +1220 -467
- package/src/mcpl/conversation-router.ts +34 -16
- package/src/mcpl/convert-block.ts +14 -1
- package/src/mcpl/hook-orchestrator.ts +33 -6
- package/src/mcpl/index.ts +6 -1
- package/src/mcpl/inference-router.ts +79 -34
- package/src/mcpl/prose-grammar.ts +7 -0
- package/src/mcpl/protocol-family.ts +2 -0
- package/src/mcpl/push-coalescer.ts +51 -13
- package/src/mcpl/reference-fetcher.ts +201 -162
- package/src/mcpl/references.ts +851 -77
- package/src/mcpl/server-connection.ts +145 -7
- package/src/mcpl/tool-lifecycle.ts +21 -0
- package/src/mcpl/tool-presence.ts +650 -0
- package/src/mcpl/tool-result-normalize.ts +18 -6
- package/src/mcpl/tool-signature.ts +285 -0
- package/src/mcpl/toolset-host.ts +637 -0
- package/src/mcpl/toolsets-module.ts +451 -0
- package/src/mcpl/transport.ts +3 -2
- package/src/mcpl/types.ts +22 -1
- package/src/mcpl/visible-content.ts +22 -9
- package/src/module-registry.ts +77 -24
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +48 -18
- package/src/modules/history/semantic.ts +308 -54
- package/src/modules/workspace/direct-write.ts +425 -0
- package/src/modules/workspace/edit-text.ts +82 -35
- package/src/modules/workspace/effects.ts +1 -1
- package/src/modules/workspace/index.ts +1274 -241
- package/src/modules/workspace/read-page.ts +222 -0
- package/src/modules/workspace/result-text.ts +119 -0
- package/src/modules/workspace/text.ts +58 -32
- package/src/modules/workspace/types.ts +41 -17
- package/src/sent-rows.ts +162 -0
- package/src/silent-heartbeat.ts +17 -10
- package/src/stable-hash.ts +14 -0
- package/src/stored-injections.ts +131 -0
- package/src/text-as-stored.ts +4 -3
- package/src/thinking-drops.ts +141 -0
- package/src/tool-image-ledger.ts +9 -8
- package/src/tool-presentation.ts +3 -1
- package/src/tool-result-guard.ts +19 -6
- package/src/tool-result-history.ts +471 -90
- package/src/tune-out/coordinator.ts +80 -37
- package/src/types/agent.ts +189 -7
- package/src/types/events.ts +15 -1
- package/src/types/framework.ts +21 -3
- package/src/types/module.ts +86 -7
- package/src/types/trace.ts +58 -2
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { constants as fsConstants } from 'node:fs';
|
|
9
|
-
import type { Stats } from 'node:fs';
|
|
9
|
+
import type { BigIntStats, Stats } from 'node:fs';
|
|
10
10
|
import { open, readdir, readlink, stat, lstat, realpath } from 'node:fs/promises';
|
|
11
11
|
import { basename, dirname, isAbsolute, join, parse, resolve, relative, sep } from 'node:path';
|
|
12
12
|
import type { JsStore, JsTreeEntry } from '@animalabs/chronicle';
|
|
@@ -45,6 +45,9 @@ import { TOOL_BATCH_HOOK_TIMEOUT_MS, ensureStateRegistered } from '../../module-
|
|
|
45
45
|
import { DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS } from '../../tool-result-history.js';
|
|
46
46
|
import { type EditSpec, type FilePlan, countBudget, decodeForEdit, hintBudget, planFile } from './edit-plan.js';
|
|
47
47
|
import { type EditProblem, type EditTool, cutLine, editList, editReceipt, fitted, refusalText } from './edit-text.js';
|
|
48
|
+
import { type FileText, fileText } from './text.js';
|
|
49
|
+
import { DEFAULT_READ_LINE_LIMIT, characterPage, linePage } from './read-page.js';
|
|
50
|
+
import { type GrepAnswer, grepText, textAnswer } from './result-text.js';
|
|
48
51
|
import {
|
|
49
52
|
type MountRuntime,
|
|
50
53
|
type PassOptions,
|
|
@@ -61,6 +64,23 @@ import {
|
|
|
61
64
|
reconcilePass,
|
|
62
65
|
settleAdoptionBeforeMutation,
|
|
63
66
|
} from './reconcile.js';
|
|
67
|
+
import { EffectFailed, unlinkContained } from './effects.js';
|
|
68
|
+
import { RecordJournal } from '../../record-journal.js';
|
|
69
|
+
import {
|
|
70
|
+
type FileIdentity,
|
|
71
|
+
type Staged,
|
|
72
|
+
FileChanged,
|
|
73
|
+
ROOT_MOVED,
|
|
74
|
+
WriteRefused,
|
|
75
|
+
checkUnchanged,
|
|
76
|
+
directWriteTestSeam,
|
|
77
|
+
discard,
|
|
78
|
+
identityOf,
|
|
79
|
+
placedStill,
|
|
80
|
+
renameInto,
|
|
81
|
+
sameIdentity,
|
|
82
|
+
stageContained,
|
|
83
|
+
} from './direct-write.js';
|
|
64
84
|
|
|
65
85
|
/** Default for how long the after-batch scan may hold the agent's next inference. */
|
|
66
86
|
const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
@@ -74,6 +94,8 @@ const AGENT_ACTION_SCAN_DEADLINE_MS = 20_000;
|
|
|
74
94
|
const CATCH_UP_BOUND_MS = 30_000;
|
|
75
95
|
/** Why a call is refused once the module has stopped. */
|
|
76
96
|
const NOT_RUNNING = 'The workspace has stopped: it takes no more work until it starts again';
|
|
97
|
+
/** writeBinary's refusal when the store's branch is no longer the one its file is for. */
|
|
98
|
+
const BRANCH_LEFT = 'the workspace had left the branch this file is for';
|
|
77
99
|
/** The workspace's own deadline must end before the framework's hook bound. */
|
|
78
100
|
const AGENT_ACTION_SCAN_DEADLINE_MAX_MS = TOOL_BATCH_HOOK_TIMEOUT_MS - 1_000;
|
|
79
101
|
/** A direct mount's ignore list when its config names none (MountConfig.ignore). */
|
|
@@ -88,6 +110,8 @@ const DIRECT_MOUNT_IGNORE: readonly string[] = ['node_modules', 'dist', '.git',
|
|
|
88
110
|
const DIRECT_READS_AT_ONCE = 16;
|
|
89
111
|
/** Why sync passes over a direct mount, as its result's `skipped` says. */
|
|
90
112
|
const DIRECT_SYNC_SKIPPED = "a direct mount: its tools read disk as it is, so there's nothing to take in";
|
|
113
|
+
/** Why materialize passes over a writable direct mount, as its result's `skipped` says. */
|
|
114
|
+
const DIRECT_MATERIALIZE_SKIPPED = "a direct mount: its writes are on disk as they're made, so there's nothing to write";
|
|
91
115
|
|
|
92
116
|
export type {
|
|
93
117
|
WorkspaceConfig,
|
|
@@ -983,6 +1007,28 @@ export class WorkspaceModule implements Module {
|
|
|
983
1007
|
*/
|
|
984
1008
|
private notDirect = new Map<string, Array<{ path: string; what: string }>>();
|
|
985
1009
|
|
|
1010
|
+
/**
|
|
1011
|
+
* Each agent's latest direct write on each mount it wrote direct, P, with
|
|
1012
|
+
* the notice of its departure from it, N (DirectHead), by mount, then by
|
|
1013
|
+
* agent. Saved at stop() in a global record (headsJournal), not the
|
|
1014
|
+
* module's branch state: they describe disk, which doesn't rewind with a
|
|
1015
|
+
* branch, so a restart on another branch, such as one an offline rollback
|
|
1016
|
+
* made, still finds them.
|
|
1017
|
+
*/
|
|
1018
|
+
private directHeads = new Map<string, Map<string, DirectHead>>();
|
|
1019
|
+
|
|
1020
|
+
/** Where directHeads is saved and loaded (DIRECT_HEADS_RECORD_TYPE), once initStore() has read it. */
|
|
1021
|
+
private headsJournal: RecordJournal<never, DirectHeadsSnapshot> | null = null;
|
|
1022
|
+
|
|
1023
|
+
/** The record initStore() read, as saved text, so a stop that would save the same adds none; null where there's one to replace. */
|
|
1024
|
+
private loadedHeads: string | null = null;
|
|
1025
|
+
|
|
1026
|
+
/** Saved entries this session doesn't take up (another root, a mount not configured direct, or none), kept for the next save. */
|
|
1027
|
+
private carriedHeads: DirectHeadsEntry[] = [];
|
|
1028
|
+
|
|
1029
|
+
/** Whether an agent's history holds a message, as the framework hands it over (useHistory). */
|
|
1030
|
+
private historyLookup: ((agentName: string, messageId: string) => boolean) | null = null;
|
|
1031
|
+
|
|
986
1032
|
constructor(config: WorkspaceConfig) {
|
|
987
1033
|
for (const mount of config.mounts) {
|
|
988
1034
|
const cap: unknown = mount.walkCap;
|
|
@@ -1116,10 +1162,64 @@ export class WorkspaceModule implements Module {
|
|
|
1116
1162
|
this.agreement.open(this.config.mounts.filter((m) => intents.has(m.name)).map((m) => ({ name: m.name, root: resolve(m.path) })));
|
|
1117
1163
|
|
|
1118
1164
|
this.chooseKinds();
|
|
1165
|
+
this.loadDirectHeads(store);
|
|
1119
1166
|
this.applySavedState();
|
|
1120
1167
|
this.ensureRunning();
|
|
1121
1168
|
}
|
|
1122
1169
|
|
|
1170
|
+
/**
|
|
1171
|
+
* The positions stop() last saved (directHeads), before any call can
|
|
1172
|
+
* write: each saved entry is a mount's positions at one root, and a mount
|
|
1173
|
+
* this session configures direct gets the entry at its own root. Every
|
|
1174
|
+
* other entry, a mount configured at another root, not direct, or not at
|
|
1175
|
+
* all, is kept for the next save (carriedHeads), since its writes are
|
|
1176
|
+
* still on disk beneath that root; a mount removed for good, and each
|
|
1177
|
+
* root a mount has moved from, leave that small entry behind. A record
|
|
1178
|
+
* the store can't read is logged, and this session starts with none and
|
|
1179
|
+
* saves none, so the record stands for a session that can read it. One
|
|
1180
|
+
* read but not of the shape saved is logged too, and this session starts
|
|
1181
|
+
* with none and its stop replaces it, so a damaged record doesn't keep
|
|
1182
|
+
* every later session from saving. Either way, a move away from the
|
|
1183
|
+
* writes before it goes untold, as after a crash.
|
|
1184
|
+
*/
|
|
1185
|
+
private loadDirectHeads(store: JsStore): void {
|
|
1186
|
+
const journal = new RecordJournal<never, DirectHeadsSnapshot>(store, { type: DIRECT_HEADS_RECORD_TYPE });
|
|
1187
|
+
const restored = new Map<string, Map<string, DirectHead>>();
|
|
1188
|
+
const carried: DirectHeadsEntry[] = [];
|
|
1189
|
+
let loaded: unknown;
|
|
1190
|
+
try {
|
|
1191
|
+
loaded = journal.load().snapshot;
|
|
1192
|
+
} catch (err) {
|
|
1193
|
+
console.error('[workspace] could not read the saved direct-write positions:', err);
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
let snapshot: DirectHeadsSnapshot;
|
|
1197
|
+
let text: string | null;
|
|
1198
|
+
try {
|
|
1199
|
+
snapshot = checkedHeads(loaded === null || loaded === undefined ? [] : loaded);
|
|
1200
|
+
text = canonicalHeads(snapshot);
|
|
1201
|
+
} catch (err) {
|
|
1202
|
+
console.error("[workspace] the saved direct-write positions aren't of the shape saved, so this session starts with none and its stop replaces them:", err);
|
|
1203
|
+
snapshot = [];
|
|
1204
|
+
text = null;
|
|
1205
|
+
}
|
|
1206
|
+
for (const saved of snapshot) {
|
|
1207
|
+
// Only a direct mount's writes are the agent's on disk, and a
|
|
1208
|
+
// store-backed one's disk follows its store.
|
|
1209
|
+
const mount = this.mounts.get(saved.mount);
|
|
1210
|
+
if (!mount || mount.config.backend !== 'direct' || saved.root !== resolve(mount.config.path)) {
|
|
1211
|
+
carried.push(saved);
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
restored.set(saved.mount, new Map(Object.entries(saved.agents).map(([agent, head]) =>
|
|
1215
|
+
[agent, { branchId: head.branchId, after: head.after, notice: head.notice ?? null }])));
|
|
1216
|
+
}
|
|
1217
|
+
this.headsJournal = journal;
|
|
1218
|
+
this.loadedHeads = text;
|
|
1219
|
+
this.carriedHeads = carried;
|
|
1220
|
+
for (const [name, heads] of restored) this.directHeads.set(name, heads);
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1123
1223
|
/**
|
|
1124
1224
|
* Whether each mount configured direct runs direct: not while the store
|
|
1125
1225
|
* holds changes there that disk lacks, on the branch selected then, so
|
|
@@ -1612,8 +1712,8 @@ export class WorkspaceModule implements Module {
|
|
|
1612
1712
|
const message = (one
|
|
1613
1713
|
? "A mount configured direct runs as 'chronicle' this session. To start it direct, settle each held change, then start it again: "
|
|
1614
1714
|
: "Mounts configured direct run as 'chronicle' this session. To start a mount direct, settle each change held at it, then start it again: ") +
|
|
1615
|
-
"sync its path to take disk's copy, or
|
|
1616
|
-
`follow the answer
|
|
1715
|
+
"sync its path to take disk's copy, or materialize it by name, a read-only one configured read-write first, " +
|
|
1716
|
+
`and follow the answer. Meanwhile it takes no writes. ${intents}${which}: ${held.slice(0, 10).join(', ')}` +
|
|
1617
1717
|
`${held.length > 10 ? `, and ${held.length - 10} more` : ''}.`;
|
|
1618
1718
|
console.warn(`[workspace] ${message}`);
|
|
1619
1719
|
this.ctx?.notifyOps?.('workspace-mount-not-direct', 'framework', message, { held: Object.fromEntries(mounts.map(([name, paths]) => [name, paths.length])) });
|
|
@@ -1683,18 +1783,58 @@ export class WorkspaceModule implements Module {
|
|
|
1683
1783
|
}
|
|
1684
1784
|
this.ctx.setState(state);
|
|
1685
1785
|
}
|
|
1786
|
+
this.saveDirectHeads();
|
|
1686
1787
|
this.ctx = null;
|
|
1687
1788
|
}
|
|
1688
1789
|
|
|
1689
|
-
/**
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
|
|
1790
|
+
/**
|
|
1791
|
+
* Save directHeads as one global record, the latest of which the next
|
|
1792
|
+
* start loads (loadDirectHeads): each mount's positions with the root
|
|
1793
|
+
* they were made under, and the entries this session didn't take up
|
|
1794
|
+
* (carriedHeads), unchanged. Only when that differs from what the
|
|
1795
|
+
* start loaded, so a session that changed nothing, or a store no direct
|
|
1796
|
+
* write reached, adds no record, and always where the record read wasn't
|
|
1797
|
+
* of the shape saved, which this replaces. Nothing where the store couldn't
|
|
1798
|
+
* read the record, so it stands. A save that fails is logged, and the next
|
|
1799
|
+
* start finds the positions of the last save that landed, as after a
|
|
1800
|
+
* crash.
|
|
1801
|
+
*/
|
|
1802
|
+
private saveDirectHeads(): void {
|
|
1803
|
+
if (!this.headsJournal) return;
|
|
1804
|
+
const snapshot: DirectHeadsSnapshot = [
|
|
1805
|
+
...this.carriedHeads,
|
|
1806
|
+
...[...this.directHeads].filter(([, heads]) => heads.size > 0).map(([name, heads]) => ({
|
|
1807
|
+
mount: name,
|
|
1808
|
+
root: resolve(this.mounts.get(name)!.config.path),
|
|
1809
|
+
agents: Object.fromEntries([...heads].map(([agent, { branchId, after, notice }]) => [agent, { branchId, after, ...(notice !== null ? { notice } : {}) }])),
|
|
1810
|
+
})),
|
|
1811
|
+
];
|
|
1812
|
+
const text = canonicalHeads(snapshot);
|
|
1813
|
+
if (text === this.loadedHeads) return;
|
|
1814
|
+
try {
|
|
1815
|
+
this.headsJournal.checkpoint(snapshot);
|
|
1816
|
+
this.loadedHeads = text;
|
|
1817
|
+
} catch (err) {
|
|
1818
|
+
console.error('[workspace] could not save the direct-write positions:', err);
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1822
|
+
/** Mount names + modes, each mount's include where it sets one, and
|
|
1823
|
+
* `backend: 'direct'` for one configured direct, whether it runs direct
|
|
1824
|
+
* this session or, while the store holds work there disk lacks, as
|
|
1825
|
+
* 'chronicle': either way it's a directory agents write directly when it
|
|
1826
|
+
* runs direct, whose files an undo leaves. For the
|
|
1827
|
+
* framework's tool-result spills, saved payloads and fetched references,
|
|
1828
|
+
* which need a mount for files of their own: the first writable one
|
|
1829
|
+
* without an include that isn't direct (firstWritableMountName), without
|
|
1830
|
+
* reaching into private state. */
|
|
1831
|
+
getMounts(): Array<{ name: string; mode: 'read-write' | 'read-only'; include?: readonly string[]; backend?: 'direct' }> {
|
|
1694
1832
|
return [...this.mounts.values()].map((m) => ({
|
|
1695
1833
|
name: m.config.name,
|
|
1696
|
-
|
|
1834
|
+
// Read-only where this session takes no writes there (writeRefusal).
|
|
1835
|
+
mode: writeRefusal(m) === null ? 'read-write' : 'read-only',
|
|
1697
1836
|
...(m.config.include ? { include: [...m.config.include] } : {}),
|
|
1837
|
+
...(m.config.backend === 'direct' ? { backend: 'direct' as const } : {}),
|
|
1698
1838
|
}));
|
|
1699
1839
|
}
|
|
1700
1840
|
|
|
@@ -1702,10 +1842,19 @@ export class WorkspaceModule implements Module {
|
|
|
1702
1842
|
// Tool Definitions
|
|
1703
1843
|
// ==========================================================================
|
|
1704
1844
|
|
|
1845
|
+
/**
|
|
1846
|
+
* Whether any of this host's mounts runs direct. The tools' descriptions
|
|
1847
|
+
* and grep's notes mention direct mounts only on such a host, so both ask
|
|
1848
|
+
* this one question.
|
|
1849
|
+
*/
|
|
1850
|
+
private anyRunsDirect(): boolean {
|
|
1851
|
+
return [...this.mounts.values()].some((m) => m.direct);
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1705
1854
|
getTools(): ToolDefinition[] {
|
|
1706
1855
|
// Where a mount runs direct, four descriptions say what that changes.
|
|
1707
1856
|
// A host with none keeps them as they were, and its residents' caches.
|
|
1708
|
-
const direct =
|
|
1857
|
+
const direct = this.anyRunsDirect();
|
|
1709
1858
|
const lsDirect = direct
|
|
1710
1859
|
? ' A direct mount (ls without a path says which, with its ignore list) lists disk\'s files as they are now: each with '
|
|
1711
1860
|
+ 'its size and no state, or a note where the mount doesn\'t take it. A path its ignore list covers is listed when you name it.'
|
|
@@ -1716,6 +1865,23 @@ export class WorkspaceModule implements Module {
|
|
|
1716
1865
|
: '';
|
|
1717
1866
|
const globDirect = direct ? ' On a direct mount, a match carries its size, or a note where the mount doesn\'t take it, and no state.' : '';
|
|
1718
1867
|
const syncDirect = direct ? ' A direct mount is listed under `skipped`: its tools read disk as it is, so there\'s nothing to take in.' : '';
|
|
1868
|
+
// Where a direct mount is writable, the four writing tools say what a
|
|
1869
|
+
// write is there: on disk when it answers, and left there by a head move.
|
|
1870
|
+
const writable = [...this.mounts.values()].some((m) => m.direct && m.config.mode !== 'read-only');
|
|
1871
|
+
const writeDirect = writable
|
|
1872
|
+
? ' On a direct mount, the file is on disk, whole and with its mode kept, when the call answers, and an undo or other head move leaves it there.'
|
|
1873
|
+
: '';
|
|
1874
|
+
const editDirect = writable
|
|
1875
|
+
? " On a direct mount, the edit applies to disk's text as it is now and is on disk when the call answers; a change another writer makes "
|
|
1876
|
+
+ 'meanwhile is kept, with the edit applied again on top of it, and an undo or other head move leaves the edit on disk.'
|
|
1877
|
+
: '';
|
|
1878
|
+
const editManyDirect = writable
|
|
1879
|
+
? " On a direct mount, every edit is checked against disk's text before any file is written, and then the files are written one by one: "
|
|
1880
|
+
+ "if a write fails partway, the answer names which files were written and which weren't, and nothing puts the written ones back. "
|
|
1881
|
+
+ 'An undo or other head move leaves them on disk.'
|
|
1882
|
+
: '';
|
|
1883
|
+
const deleteDirect = writable ? ' On a direct mount, the file is removed from disk at once, and an undo or other head move doesn\'t bring it back.' : '';
|
|
1884
|
+
const materializeDirect = writable ? ' A direct mount writes its files to disk as they\'re made, so materialize passes over it, under `skipped` where you name it.' : '';
|
|
1719
1885
|
return [
|
|
1720
1886
|
{
|
|
1721
1887
|
name: 'read',
|
|
@@ -1749,7 +1915,7 @@ export class WorkspaceModule implements Module {
|
|
|
1749
1915
|
},
|
|
1750
1916
|
{
|
|
1751
1917
|
name: 'write',
|
|
1752
|
-
description: 'Create or overwrite a file in the workspace.',
|
|
1918
|
+
description: 'Create or overwrite a file in the workspace.' + writeDirect,
|
|
1753
1919
|
inputSchema: {
|
|
1754
1920
|
type: 'object' as const,
|
|
1755
1921
|
properties: {
|
|
@@ -1770,7 +1936,7 @@ export class WorkspaceModule implements Module {
|
|
|
1770
1936
|
+ "Only the replaced text changes, and the new text's line ends take those of the text it replaces. "
|
|
1771
1937
|
+ 'A change disk made to the file is taken in first, and the answer says so. '
|
|
1772
1938
|
+ 'The answer shows the changed region, numbered, a long line from shortly before the change on it, as its length allows. '
|
|
1773
|
-
+ 'For several edits at once, in one file or across files, applied all or none, use edit_many.',
|
|
1939
|
+
+ 'For several edits at once, in one file or across files, applied all or none, use edit_many.' + editDirect,
|
|
1774
1940
|
inputSchema: {
|
|
1775
1941
|
type: 'object' as const,
|
|
1776
1942
|
properties: {
|
|
@@ -1792,7 +1958,8 @@ export class WorkspaceModule implements Module {
|
|
|
1792
1958
|
+ "A refusal names the problems it finds, each by its edit's number in the list (from 1). "
|
|
1793
1959
|
+ 'All or none holds in the workspace: on a mount that writes to disk, disk can still refuse a file, '
|
|
1794
1960
|
+ "and the answer then says every edit is stored and names each file disk refused, with its push's reason. "
|
|
1795
|
-
+ "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
|
|
1961
|
+
+ "The answer shows each edit's first changed region, numbered, a long line from shortly before each change on it, as its length allows."
|
|
1962
|
+
+ editManyDirect,
|
|
1796
1963
|
inputSchema: {
|
|
1797
1964
|
type: 'object' as const,
|
|
1798
1965
|
properties: {
|
|
@@ -1821,7 +1988,7 @@ export class WorkspaceModule implements Module {
|
|
|
1821
1988
|
name: 'delete',
|
|
1822
1989
|
description: 'Delete a file from the workspace. On a mount without autoMaterialize the disk copy stays, '
|
|
1823
1990
|
+ 'listed as workspace-deleted (no scan brings it back), until materialize with applyDeletions removes it '
|
|
1824
|
-
+ 'or a sync of the path restores it.',
|
|
1991
|
+
+ 'or a sync of the path restores it.' + deleteDirect,
|
|
1825
1992
|
inputSchema: {
|
|
1826
1993
|
type: 'object' as const,
|
|
1827
1994
|
properties: {
|
|
@@ -1874,7 +2041,7 @@ export class WorkspaceModule implements Module {
|
|
|
1874
2041
|
properties: {
|
|
1875
2042
|
pattern: { type: 'string', description: 'Regular expression pattern' },
|
|
1876
2043
|
path: { type: 'string', description: 'File or directory to search (mount-prefixed, optional)' },
|
|
1877
|
-
glob: { type: 'string', description: 'Glob pattern to filter files
|
|
2044
|
+
glob: { type: 'string', description: 'Glob pattern to filter files: one with a "/" is matched from the mount\'s root, whatever the path (e.g., "src/**/*.ts"), and in the mount its first segment names, read mount-prefixed (e.g., "project/src/**/*.ts"), in every other mount as written; one with no "/" also matches a file\'s name at any depth (e.g., "*.ts")' },
|
|
1878
2045
|
contextBefore: { type: 'integer', description: 'Context lines before match' },
|
|
1879
2046
|
contextAfter: { type: 'integer', description: 'Context lines after match' },
|
|
1880
2047
|
},
|
|
@@ -1896,7 +2063,7 @@ export class WorkspaceModule implements Module {
|
|
|
1896
2063
|
description: 'Write workspace files to the real filesystem. Use after writing/editing to push changes to disk. '
|
|
1897
2064
|
+ 'Never deletes disk files unless applyDeletions is given; lists workspace deletions it left on disk. '
|
|
1898
2065
|
+ 'On a mount with an include, what the include doesn\'t cover is never written, and one naming a '
|
|
1899
|
-
+ 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.',
|
|
2066
|
+
+ 'directory counts what it left beneath it under `passedOver`, by mount and reason, without naming it.' + materializeDirect,
|
|
1900
2067
|
inputSchema: {
|
|
1901
2068
|
type: 'object' as const,
|
|
1902
2069
|
properties: {
|
|
@@ -2027,21 +2194,26 @@ export class WorkspaceModule implements Module {
|
|
|
2027
2194
|
return { success: false, isError: true, error: `The ${call.name} call did nothing: ${problem}.` };
|
|
2028
2195
|
}
|
|
2029
2196
|
const input = call.input as Record<string, unknown>;
|
|
2197
|
+
// The most of this call's result that can reach its caller whole: past
|
|
2198
|
+
// it, a model's result spills into a file, and a script gets
|
|
2199
|
+
// ResultTooLarge in its place. A call made in process carries none and
|
|
2200
|
+
// takes the data itself, so its read pages by its line limit alone.
|
|
2201
|
+
const resultMaxChars = call.resultMaxChars;
|
|
2030
2202
|
const generated = typeof input?.path === 'string' ? this.findGeneratedTextFile(input.path) : undefined;
|
|
2031
2203
|
if (generated) {
|
|
2032
2204
|
if (call.name === 'read' && call.callerAgentName === generated.agentName) {
|
|
2033
|
-
return await this.handleRead(input as unknown as ReadInput, generated.read());
|
|
2205
|
+
return await this.handleRead(input as unknown as ReadInput, resultMaxChars, generated.read());
|
|
2034
2206
|
}
|
|
2035
2207
|
return { success: false, isError: true, error: this.generatedFileRefusal(generated, call.callerAgentName) };
|
|
2036
2208
|
}
|
|
2037
2209
|
|
|
2038
2210
|
switch (call.name) {
|
|
2039
|
-
case 'read': return await this.handleRead(input as unknown as ReadInput);
|
|
2211
|
+
case 'read': return await this.handleRead(input as unknown as ReadInput, resultMaxChars);
|
|
2040
2212
|
case 'read_image': return await this.handleReadImage(input as unknown as ReadImageInput);
|
|
2041
|
-
case 'write': return await this.handleWrite(input as unknown as WriteInput);
|
|
2042
|
-
case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars);
|
|
2213
|
+
case 'write': return await this.handleWrite(input as unknown as WriteInput, call.callerAgentName);
|
|
2214
|
+
case 'edit': return await this.handleEdit(input as unknown as EditInput, call.resultMaxChars, call.callerAgentName);
|
|
2043
2215
|
case 'edit_many': return await this.handleEditMany(input as unknown as EditManyInput, call.resultMaxChars, call.callerAgentName);
|
|
2044
|
-
case 'delete': return await this.handleDelete(input as unknown as DeleteInput);
|
|
2216
|
+
case 'delete': return await this.handleDelete(input as unknown as DeleteInput, call.callerAgentName);
|
|
2045
2217
|
case 'ls': return await this.handleLs(input as unknown as LsInput);
|
|
2046
2218
|
case 'glob': return await this.handleGlob(input as unknown as GlobInput);
|
|
2047
2219
|
case 'grep': return await this.handleGrep(input as unknown as GrepInput);
|
|
@@ -2182,6 +2354,12 @@ export class WorkspaceModule implements Module {
|
|
|
2182
2354
|
* withheld original this way, for the branch its stub is on
|
|
2183
2355
|
* (agent-framework #277).
|
|
2184
2356
|
*
|
|
2357
|
+
* On a mount running direct, the bytes go to disk as a direct `write`'s
|
|
2358
|
+
* do, whole or not at all, and nothing goes into the store: `branch` is
|
|
2359
|
+
* checked before the write starts and again just before its rename, and
|
|
2360
|
+
* `agentName` names the agent whose write it is for the head-move notice
|
|
2361
|
+
* (a write naming none tells no agent).
|
|
2362
|
+
*
|
|
2185
2363
|
* A write whose tree entry is set but that then fails returns success
|
|
2186
2364
|
* false with `data: { inChronicle: true }`: its copy on disk was refused,
|
|
2187
2365
|
* the store failed after recording the entry, which skips the push where
|
|
@@ -2195,7 +2373,7 @@ export class WorkspaceModule implements Module {
|
|
|
2195
2373
|
mountPrefixedPath: string,
|
|
2196
2374
|
data: Buffer,
|
|
2197
2375
|
mimeType: string,
|
|
2198
|
-
options: { branch?: string } = {},
|
|
2376
|
+
options: { branch?: string; agentName?: string } = {},
|
|
2199
2377
|
): Promise<ToolResult> {
|
|
2200
2378
|
if (!this.running()) return { success: false, isError: true, error: NOT_RUNNING };
|
|
2201
2379
|
return this.underWay(this.writeBinaryUnderWay(mountPrefixedPath, data, mimeType, options));
|
|
@@ -2205,7 +2383,7 @@ export class WorkspaceModule implements Module {
|
|
|
2205
2383
|
mountPrefixedPath: string,
|
|
2206
2384
|
data: Buffer,
|
|
2207
2385
|
mimeType: string,
|
|
2208
|
-
options: { branch?: string },
|
|
2386
|
+
options: { branch?: string; agentName?: string },
|
|
2209
2387
|
): Promise<ToolResult> {
|
|
2210
2388
|
let mount: MountState;
|
|
2211
2389
|
let relativePath: string;
|
|
@@ -2218,20 +2396,32 @@ export class WorkspaceModule implements Module {
|
|
|
2218
2396
|
isError: true,
|
|
2219
2397
|
};
|
|
2220
2398
|
}
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
}
|
|
2399
|
+
const refusal = writeRefusal(mount);
|
|
2400
|
+
if (refusal) return { success: false, error: refusal, isError: true };
|
|
2224
2401
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
2225
2402
|
if (data.byteLength > maxSize) {
|
|
2226
2403
|
return { success: false, error: `Content exceeds max file size (${maxSize} bytes)`, isError: true };
|
|
2227
2404
|
}
|
|
2228
2405
|
const store = this.getStore();
|
|
2406
|
+
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect),
|
|
2407
|
+
// as the caller's agent's write, where it names one. A branch it names
|
|
2408
|
+
// is checked just before the rename, since disk is no branch's.
|
|
2409
|
+
if (mount.direct) {
|
|
2410
|
+
// Checked first too, so a request already for a left branch makes no
|
|
2411
|
+
// directory and stages nothing.
|
|
2412
|
+
if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
|
|
2413
|
+
return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
|
|
2414
|
+
}
|
|
2415
|
+
const written = await this.writeDirect(mount, relativePath, mountPrefixedPath, data, options.agentName, options.branch);
|
|
2416
|
+
if ('error' in written) return { success: false, isError: true, error: written.error, ...(written.branchLeft ? { data: { branchLeft: true } } : {}) };
|
|
2417
|
+
return { success: true, data: { path: mountPrefixedPath, size: data.byteLength, mimeType, ...(written.note ? { note: written.note } : {}) } };
|
|
2418
|
+
}
|
|
2229
2419
|
return this.withMount(mount, async () => {
|
|
2230
2420
|
await this.settleBeforeMutation(mount, relativePath);
|
|
2231
2421
|
// Checked inside the mount's turn, after anything the write waited on:
|
|
2232
2422
|
// commitToolChange sets the tree entry before it first yields.
|
|
2233
2423
|
if (options.branch !== undefined && store.currentBranch().name !== options.branch) {
|
|
2234
|
-
return { success: false, error:
|
|
2424
|
+
return { success: false, error: BRANCH_LEFT, isError: true, data: { branchLeft: true } };
|
|
2235
2425
|
}
|
|
2236
2426
|
const blobHash = store.storeBlob(data, mimeType);
|
|
2237
2427
|
// An overwrite keeps the file's mode, as the write tool's does.
|
|
@@ -2268,11 +2458,15 @@ export class WorkspaceModule implements Module {
|
|
|
2268
2458
|
}
|
|
2269
2459
|
|
|
2270
2460
|
/**
|
|
2271
|
-
* Read a file's raw bytes from a mount
|
|
2272
|
-
*
|
|
2273
|
-
* readBinaryFromDisk reads it, at most the mount's
|
|
2274
|
-
*
|
|
2275
|
-
*
|
|
2461
|
+
* Read a file's raw bytes from a mount: through the Chronicle tree, a path
|
|
2462
|
+
* the workspace doesn't hold observed first (ensureSynced); or, on a direct
|
|
2463
|
+
* mount, from disk as readBinaryFromDisk reads it, at most the mount's
|
|
2464
|
+
* `maxFileSize`. A path the workspace holds is read as the workspace holds
|
|
2465
|
+
* it, not settled as the `read` tool settles it: the tree takes in no
|
|
2466
|
+
* binary disk copy, so a pass over a held binary file that disk changed
|
|
2467
|
+
* would drop the workspace's copy. Public API for the framework's
|
|
2468
|
+
* synthesized `read_image` tool, where the workspace module doesn't serve
|
|
2469
|
+
* read_image itself, and other peer callers that need binary content.
|
|
2276
2470
|
*/
|
|
2277
2471
|
async readBinary(
|
|
2278
2472
|
mountPrefixedPath: string,
|
|
@@ -2490,8 +2684,10 @@ export class WorkspaceModule implements Module {
|
|
|
2490
2684
|
* mount nested by its directories alone, since its real root holds this
|
|
2491
2685
|
* one, is judged by the parse's nesting, so a root repointed between a
|
|
2492
2686
|
* read's parse and its open can have it serve a path in that mount's
|
|
2493
|
-
* directory
|
|
2494
|
-
*
|
|
2687
|
+
* directory, and one repointed between a direct write's parse and its
|
|
2688
|
+
* staging can have the write land there. Each mount is placed as a start
|
|
2689
|
+
* places it (placementOf), from where its real root and the directories
|
|
2690
|
+
* above its configured path lie on
|
|
2495
2691
|
* disk. For a mount configured at this mount's own path or beneath it,
|
|
2496
2692
|
* that's where they lie now: beneath `rootReal` where they lie beneath this
|
|
2497
2693
|
* mount's path, and the directories above this mount's path as resolving
|
|
@@ -3681,15 +3877,19 @@ export class WorkspaceModule implements Module {
|
|
|
3681
3877
|
}
|
|
3682
3878
|
|
|
3683
3879
|
/**
|
|
3684
|
-
* Before a
|
|
3685
|
-
* three-way rule, which ingests a new disk file but
|
|
3686
|
-
* workspace deletion or a file absent from this branch.
|
|
3687
|
-
* path
|
|
3688
|
-
*
|
|
3689
|
-
*
|
|
3690
|
-
*
|
|
3691
|
-
* the
|
|
3692
|
-
*
|
|
3880
|
+
* Before a deletion, and for readBinary: a path the workspace doesn't hold
|
|
3881
|
+
* is observed under the three-way rule, which ingests a new disk file but
|
|
3882
|
+
* never brings back a workspace deletion or a file absent from this branch.
|
|
3883
|
+
* A path it holds is left alone. For a deletion, as for a write, taking
|
|
3884
|
+
* disk's change in first would let the push remove that change unseen; for
|
|
3885
|
+
* readBinary, a pass would drop a held binary file that disk changed, since
|
|
3886
|
+
* the tree takes in no binary disk copy. The pass names the path, as a file
|
|
3887
|
+
* tool names the one it acts on: a path the mount passes over by design is
|
|
3888
|
+
* observed, as a sync naming it would observe it (#276), while a path in
|
|
3889
|
+
* another mount's directory is refused before this, and passed over by any
|
|
3890
|
+
* pass. Answers the pass's report on the path, and why the pass decided
|
|
3891
|
+
* nothing where it did (undecidedOf), which leaves disk unchecked. The
|
|
3892
|
+
* caller holds the mount's turn.
|
|
3693
3893
|
*/
|
|
3694
3894
|
private async ensureSynced(mount: MountState, relativePath: string): Promise<{ report: PathReport | undefined; undecided: string | null }> {
|
|
3695
3895
|
const store = this.getStore();
|
|
@@ -3728,21 +3928,52 @@ export class WorkspaceModule implements Module {
|
|
|
3728
3928
|
}
|
|
3729
3929
|
|
|
3730
3930
|
/**
|
|
3731
|
-
* A file's bytes for read,
|
|
3732
|
-
* workspace's copy,
|
|
3733
|
-
*
|
|
3734
|
-
* (bytesOnDisk)
|
|
3931
|
+
* A file's bytes for read, and the notes that say which copy they are. On a
|
|
3932
|
+
* 'chronicle' mount it's the workspace's copy, after a pass over the path
|
|
3933
|
+
* takes disk's change to it in first. On a direct mount it's disk's file as
|
|
3934
|
+
* it is now (bytesOnDisk), with no notes, since nothing else is kept. An
|
|
3935
|
+
* error says why there are none to read.
|
|
3735
3936
|
*/
|
|
3736
|
-
private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer } | { error: string }> {
|
|
3737
|
-
if (mount.direct)
|
|
3937
|
+
private async bytesOf(mount: MountState, relativePath: string, path: string): Promise<{ bytes: Buffer; notes: string[] } | { error: string }> {
|
|
3938
|
+
if (mount.direct) {
|
|
3939
|
+
const read = await this.bytesOnDisk(mount, relativePath, path);
|
|
3940
|
+
return 'error' in read ? read : { bytes: read.bytes, notes: [] };
|
|
3941
|
+
}
|
|
3738
3942
|
const store = this.getStore();
|
|
3739
|
-
return this.withMount(mount, async () => {
|
|
3740
|
-
|
|
3943
|
+
return this.withMount(mount, async (): Promise<{ bytes: Buffer; notes: string[] } | { error: string }> => {
|
|
3944
|
+
// A read shows the path as disk holds it now: a change on disk to a
|
|
3945
|
+
// file the workspace holds is taken in first, by the rule a listing
|
|
3946
|
+
// uses, as an edit's is. Where the stored copy is a draft and disk
|
|
3947
|
+
// changed too, the read shows the draft and names the conflict. The
|
|
3948
|
+
// pass names the path, as a file tool names the one it acts on, so a
|
|
3949
|
+
// path the mount passes over by design is observed, as a sync naming
|
|
3950
|
+
// it would observe it (#276).
|
|
3951
|
+
const pass = await this.passUnlocked(mount, { kind: 'paths', paths: [relativePath] }, { named: true });
|
|
3952
|
+
// A pass that decided nothing (undecidedOf: the selected branch kept
|
|
3953
|
+
// changing) leaves no report. It's read as a copy disk couldn't be
|
|
3954
|
+
// checked, with its reason, so the answer says the check didn't finish.
|
|
3955
|
+
const undecided = undecidedOf(pass);
|
|
3956
|
+
const report = pass.reports.get(relativePath)
|
|
3957
|
+
?? (undecided !== null ? { path: relativePath, state: 'unverified' as const, note: undecided } : undefined);
|
|
3958
|
+
// A check that decided nothing may well finish on the next read, as
|
|
3959
|
+
// grep's answer to the same says of grep.
|
|
3960
|
+
const again = undecided !== null ? ['Read again to check it.'] : [];
|
|
3961
|
+
// A mode taken in alone isn't a change to the text, so it leaves the
|
|
3962
|
+
// read nothing to say, as it leaves an edit nothing.
|
|
3963
|
+
const took = (op: TreeOp) => pass.ops.some((o) => o.path === relativePath && o.op === op && !o.modeOnly);
|
|
3741
3964
|
const entry = store.treeGet(mount.treeStateId, relativePath);
|
|
3742
|
-
if (!entry)
|
|
3965
|
+
if (!entry) {
|
|
3966
|
+
// A pass also drops a copy disk made binary or too large, which
|
|
3967
|
+
// the disk-only answer describes; any other drop is disk's deletion.
|
|
3968
|
+
return {
|
|
3969
|
+
error: took('deleted') && report?.state !== 'disk-only'
|
|
3970
|
+
? `File not found: ${path}. Disk's copy was deleted since the workspace last agreed with it, and that was taken in first.`
|
|
3971
|
+
: [this.notInWorkspace(path, report), ...again].join(' '),
|
|
3972
|
+
};
|
|
3973
|
+
}
|
|
3743
3974
|
const blob = store.getBlob(entry.blobHash);
|
|
3744
3975
|
if (!blob) return { error: `Blob not found for: ${path}` };
|
|
3745
|
-
return { bytes: blob };
|
|
3976
|
+
return { bytes: blob, notes: [...whichCopyRead(report, took('modified'), mount.config.mode === 'read-only'), ...again] };
|
|
3746
3977
|
});
|
|
3747
3978
|
}
|
|
3748
3979
|
|
|
@@ -3765,8 +3996,7 @@ export class WorkspaceModule implements Module {
|
|
|
3765
3996
|
} catch (err) {
|
|
3766
3997
|
// A refusal that names only the path says why with its errno, as a
|
|
3767
3998
|
// failed read below does.
|
|
3768
|
-
|
|
3769
|
-
return { error: bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err) };
|
|
3999
|
+
return { error: containedRefusal(err, path) };
|
|
3770
4000
|
}
|
|
3771
4001
|
try {
|
|
3772
4002
|
// No more than the limit is loaded, as for a file over it at its fstat.
|
|
@@ -3788,92 +4018,38 @@ export class WorkspaceModule implements Module {
|
|
|
3788
4018
|
// Tool Handlers
|
|
3789
4019
|
// ==========================================================================
|
|
3790
4020
|
|
|
3791
|
-
|
|
4021
|
+
/**
|
|
4022
|
+
* A read's page, held to `maxChars`, the most of this call's result that
|
|
4023
|
+
* can reach its caller whole. A call that carries none, made in process,
|
|
4024
|
+
* takes the data itself: its page holds to its line limit alone, with every
|
|
4025
|
+
* line whole, as it did before pages had a budget.
|
|
4026
|
+
*/
|
|
4027
|
+
private async handleRead(input: ReadInput, maxChars: number | undefined, generatedContent?: string): Promise<ToolResult> {
|
|
3792
4028
|
const characterPaging = input.offsetChars !== undefined || input.limitChars !== undefined;
|
|
3793
|
-
const offsetChars = input.offsetChars ?? 0;
|
|
3794
|
-
const limitChars = input.limitChars ?? 2000;
|
|
3795
4029
|
// Each number is a whole number of at least its schema's minimum by now
|
|
3796
4030
|
// (inputProblem).
|
|
3797
4031
|
if (characterPaging && (input.offset !== undefined || input.limit !== undefined)) {
|
|
3798
4032
|
return { success: false, isError: true, error: 'Use either offset/limit (lines) or offsetChars/limitChars (characters), not both.' };
|
|
3799
4033
|
}
|
|
3800
|
-
let
|
|
3801
|
-
|
|
4034
|
+
let file: FileText;
|
|
4035
|
+
let notes: string[] = [];
|
|
4036
|
+
if (generatedContent === undefined) {
|
|
3802
4037
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3803
4038
|
input = { ...input, path };
|
|
3804
4039
|
const found = await this.bytesOf(mount, relativePath, path);
|
|
3805
4040
|
if ('error' in found) return { success: false, error: found.error, isError: true };
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
const splitsPair = (at: number): boolean =>
|
|
3811
|
-
at > 0 && at < content.length
|
|
3812
|
-
&& content.charCodeAt(at - 1) >= 0xd800 && content.charCodeAt(at - 1) <= 0xdbff
|
|
3813
|
-
&& content.charCodeAt(at) >= 0xdc00 && content.charCodeAt(at) <= 0xdfff;
|
|
3814
|
-
if (splitsPair(start)) {
|
|
3815
|
-
return { success: false, isError: true, error: 'offsetChars splits a surrogate pair; use nextOffsetChars from the previous page.' };
|
|
3816
|
-
}
|
|
3817
|
-
let end = start + Math.min(limitChars, content.length - start);
|
|
3818
|
-
if (splitsPair(end)) end++;
|
|
3819
|
-
return {
|
|
3820
|
-
success: true,
|
|
3821
|
-
data: {
|
|
3822
|
-
path: input.path,
|
|
3823
|
-
totalChars: content.length,
|
|
3824
|
-
offsetChars: start,
|
|
3825
|
-
nextOffsetChars: end < content.length ? end : null,
|
|
3826
|
-
content: content.slice(start, end),
|
|
3827
|
-
},
|
|
3828
|
-
};
|
|
3829
|
-
}
|
|
3830
|
-
const lines = linesOf(content);
|
|
3831
|
-
|
|
3832
|
-
// Apply offset/limit. An unlimited read of a large file would inject the
|
|
3833
|
-
// whole thing into the turn (a 2MB file ≈ 600k tokens → the request blows
|
|
3834
|
-
// past the model context and 400s), so an omitted limit falls back to a
|
|
3835
|
-
// default cap; the result reports total/from/to so the agent can page.
|
|
3836
|
-
const DEFAULT_READ_LINE_LIMIT = 2000;
|
|
3837
|
-
const startLine = (input.offset ?? 1) - 1; // Convert to 0-indexed
|
|
3838
|
-
if (startLine >= lines.length) {
|
|
3839
|
-
// Nothing to show: an empty file, or an offset past the last line. No
|
|
3840
|
-
// range is claimed, and the line count says where the file ends.
|
|
3841
|
-
return {
|
|
3842
|
-
success: true,
|
|
3843
|
-
data: {
|
|
3844
|
-
path: input.path,
|
|
3845
|
-
totalLines: lines.length,
|
|
3846
|
-
// Reading an empty file from its start is reading it whole.
|
|
3847
|
-
...(startLine > 0
|
|
3848
|
-
? { note: `Line ${startLine + 1} is past the end: the file has ${lines.length} line${lines.length === 1 ? '' : 's'}.` }
|
|
3849
|
-
: {}),
|
|
3850
|
-
content: '',
|
|
3851
|
-
},
|
|
3852
|
-
};
|
|
4041
|
+
file = fileText(found.bytes);
|
|
4042
|
+
notes = found.notes;
|
|
4043
|
+
} else {
|
|
4044
|
+
file = fileText(Buffer.from(generatedContent, 'utf-8'));
|
|
3853
4045
|
}
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
.map((line, i) => `${String(startLine + i + 1).padStart(6)}\t${line}`)
|
|
3862
|
-
.join('\n');
|
|
3863
|
-
|
|
3864
|
-
return {
|
|
3865
|
-
success: true,
|
|
3866
|
-
data: {
|
|
3867
|
-
path: input.path,
|
|
3868
|
-
totalLines: lines.length,
|
|
3869
|
-
fromLine: startLine + 1,
|
|
3870
|
-
toLine: Math.min(endLine, lines.length),
|
|
3871
|
-
...(truncated
|
|
3872
|
-
? { note: `Truncated at ${effectiveLimit} lines (file has ${lines.length}). Use offset/limit to read more.` }
|
|
3873
|
-
: {}),
|
|
3874
|
-
content: formatted,
|
|
3875
|
-
},
|
|
3876
|
-
};
|
|
4046
|
+
// An unlimited read of a large file would inject the whole thing into the
|
|
4047
|
+
// turn (a 2MB file ≈ 600k tokens → the request blows past the model
|
|
4048
|
+
// context and 400s), so an omitted limit falls back to a default, and a
|
|
4049
|
+
// page stops at the call's result limit where it has one.
|
|
4050
|
+
return characterPaging
|
|
4051
|
+
? characterPage(input.path, file, input.offsetChars ?? 0, input.limitChars ?? 2000, maxChars, notes)
|
|
4052
|
+
: linePage(input.path, file, input.offset ?? 1, input.limit ?? DEFAULT_READ_LINE_LIMIT, maxChars, notes);
|
|
3877
4053
|
}
|
|
3878
4054
|
|
|
3879
4055
|
private async handleReadImage(input: ReadImageInput): Promise<ToolResult> {
|
|
@@ -3925,15 +4101,14 @@ export class WorkspaceModule implements Module {
|
|
|
3925
4101
|
}
|
|
3926
4102
|
}
|
|
3927
4103
|
|
|
3928
|
-
private async handleWrite(input: WriteInput): Promise<ToolResult> {
|
|
4104
|
+
private async handleWrite(input: WriteInput, callerAgentName?: string): Promise<ToolResult> {
|
|
3929
4105
|
if (LONE_SURROGATE.test(input.content)) {
|
|
3930
4106
|
return { success: false, isError: true, error: 'The write did nothing: "content" holds a lone UTF-16 surrogate, which UTF-8 can\'t store.' };
|
|
3931
4107
|
}
|
|
3932
4108
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
3933
4109
|
input = { ...input, path };
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
}
|
|
4110
|
+
const refusal = writeRefusal(mount);
|
|
4111
|
+
if (refusal) return { success: false, error: refusal, isError: true };
|
|
3937
4112
|
|
|
3938
4113
|
const store = this.getStore();
|
|
3939
4114
|
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
@@ -3943,6 +4118,12 @@ export class WorkspaceModule implements Module {
|
|
|
3943
4118
|
|
|
3944
4119
|
const buffer = Buffer.from(input.content, 'utf-8');
|
|
3945
4120
|
const receipt = { path: input.path, size: buffer.byteLength, hash: hashContent(input.content) };
|
|
4121
|
+
// A direct mount's bytes go to disk, whole, or nowhere (writeDirect).
|
|
4122
|
+
if (mount.direct) {
|
|
4123
|
+
const written = await this.writeDirect(mount, relativePath, input.path, buffer, callerAgentName);
|
|
4124
|
+
if ('error' in written) return { success: false, isError: true, error: written.error };
|
|
4125
|
+
return { success: true, data: { ...receipt, ...(written.note ? { note: written.note } : {}) } };
|
|
4126
|
+
}
|
|
3946
4127
|
let committed = false;
|
|
3947
4128
|
let refused: string | null;
|
|
3948
4129
|
let modeKept: string | null;
|
|
@@ -3966,13 +4147,13 @@ export class WorkspaceModule implements Module {
|
|
|
3966
4147
|
return { success: true, data: { ...receipt, ...(modeKept ? { modeKept } : {}) } };
|
|
3967
4148
|
}
|
|
3968
4149
|
|
|
3969
|
-
private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS): Promise<ToolResult> {
|
|
4150
|
+
private async handleEdit(input: EditInput, maxChars = DEFAULT_TOOL_RESULT_INLINE_MAX_CHARS, callerAgentName?: string): Promise<ToolResult> {
|
|
3970
4151
|
return this.applyEdits('edit', [{
|
|
3971
4152
|
path: input.path,
|
|
3972
4153
|
oldString: input.oldString,
|
|
3973
4154
|
newString: input.newString,
|
|
3974
4155
|
expected: input.replaceAll ? 'all' : 1,
|
|
3975
|
-
}], maxChars);
|
|
4156
|
+
}], maxChars, callerAgentName);
|
|
3976
4157
|
}
|
|
3977
4158
|
|
|
3978
4159
|
private async handleEditMany(
|
|
@@ -4093,6 +4274,9 @@ export class WorkspaceModule implements Module {
|
|
|
4093
4274
|
const [mount] = mounts.values();
|
|
4094
4275
|
if (tool === 'edit' && problems.length) return refuse(problems);
|
|
4095
4276
|
if (!mount || mounts.size > 1) return refuse(problems);
|
|
4277
|
+
// A direct mount's edits apply to disk's text as it is now, and land on
|
|
4278
|
+
// disk or don't happen (applyDirectEdits).
|
|
4279
|
+
if (mount.direct) return this.applyDirectEdits(tool, mount, [...targets.values()], problems, given.length, maxChars, callerAgentName);
|
|
4096
4280
|
|
|
4097
4281
|
const store = this.getStore();
|
|
4098
4282
|
// Whether the commit set the call's entries, all of them at once.
|
|
@@ -4202,48 +4386,11 @@ export class WorkspaceModule implements Module {
|
|
|
4202
4386
|
problems.push({ kind: 'file', edits: target.edits, path: target.path, text: `Blob not found for: ${target.path}`, whole: true });
|
|
4203
4387
|
continue;
|
|
4204
4388
|
}
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
const file = decodeForEdit(blob);
|
|
4208
|
-
if (!file) {
|
|
4209
|
-
problems.push({
|
|
4210
|
-
kind: 'file',
|
|
4211
|
-
edits: target.edits,
|
|
4212
|
-
path: target.path,
|
|
4213
|
-
text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
|
|
4214
|
-
+ 'Read shows each invalid byte sequence as U+FFFD, and a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.',
|
|
4215
|
-
whole: false,
|
|
4216
|
-
});
|
|
4217
|
-
continue;
|
|
4218
|
-
}
|
|
4219
|
-
if (!target.specs.length) continue;
|
|
4220
|
-
const planned = planFile(file, blob, target.specs, hints, maxSize, counts);
|
|
4221
|
-
if ('problems' in planned) {
|
|
4222
|
-
// One at a time: a call of many edits can have more problems
|
|
4223
|
-
// than a spread passes as arguments.
|
|
4224
|
-
for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
|
|
4225
|
-
continue;
|
|
4226
|
-
}
|
|
4227
|
-
// The planner refuses a file its counts take past the limit before
|
|
4228
|
-
// building it, from the least its bytes can be; one it built past
|
|
4229
|
-
// the limit, as its CRs or multi-byte characters can take it, is
|
|
4230
|
-
// refused here.
|
|
4231
|
-
if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
|
|
4232
|
-
const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
|
|
4233
|
-
problems.push({
|
|
4234
|
-
kind: 'file',
|
|
4235
|
-
edits: target.edits,
|
|
4236
|
-
path: target.path,
|
|
4237
|
-
text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
|
|
4238
|
-
+ `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
|
|
4239
|
-
+ "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
|
|
4240
|
-
whole: false,
|
|
4241
|
-
});
|
|
4242
|
-
continue;
|
|
4243
|
-
}
|
|
4389
|
+
const plan = planTargetEdits(tool, target, blob, hints, maxSize, counts, problems);
|
|
4390
|
+
if (plan === null) continue;
|
|
4244
4391
|
plans.push({
|
|
4245
4392
|
target,
|
|
4246
|
-
plan
|
|
4393
|
+
plan,
|
|
4247
4394
|
mode: entry.mode,
|
|
4248
4395
|
backOnDisk,
|
|
4249
4396
|
conflictNote: pushes ? null : conflictNote(tool === 'edit' || target.edits.length === 1 ? 'is in' : 'are in'),
|
|
@@ -4258,15 +4405,12 @@ export class WorkspaceModule implements Module {
|
|
|
4258
4405
|
change: { kind: 'set' as const, blobHash: store.storeBlob(plan.bytes, 'text/plain'), size: plan.bytes.length, mode },
|
|
4259
4406
|
})), () => { committed = true; });
|
|
4260
4407
|
if (refused.size > 0) return this.editsStoredButUnfinished(tool, [...targets.values()], applied, conflicts, { refused, modeKept }, maxChars);
|
|
4261
|
-
return {
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4268
|
-
})), given.length, maxChars),
|
|
4269
|
-
};
|
|
4408
|
+
return editReceipt(tool, plans.map(({ target, plan, backOnDisk, conflictNote }) => ({
|
|
4409
|
+
path: target.path,
|
|
4410
|
+
plan,
|
|
4411
|
+
notes: [applied.get(target) ?? null, backOnDisk, conflictNote].filter((note): note is string => note !== null),
|
|
4412
|
+
modeKept: modeKept.get(target.relativePath) ?? null,
|
|
4413
|
+
})), given.length, maxChars);
|
|
4270
4414
|
});
|
|
4271
4415
|
} catch (error) {
|
|
4272
4416
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -4321,7 +4465,8 @@ export class WorkspaceModule implements Module {
|
|
|
4321
4465
|
// One that names the mount's root, its bare name included, names no
|
|
4322
4466
|
// file.
|
|
4323
4467
|
if (where.relativePath === '') return unusable(`${path} names the root of mount "${where.mount.config.name}", which isn't a file; ls ${where.mount.config.name} lists the files in it.`);
|
|
4324
|
-
|
|
4468
|
+
const refusal = writeRefusal(where.mount);
|
|
4469
|
+
if (refusal) return unusable(refusal);
|
|
4325
4470
|
const generated = tool === 'edit_many' ? this.findGeneratedTextFile(path) : undefined;
|
|
4326
4471
|
if (generated) return unusable(`${path}: ${this.generatedFileRefusal(generated, callerAgentName)}.`);
|
|
4327
4472
|
return where;
|
|
@@ -4405,12 +4550,513 @@ export class WorkspaceModule implements Module {
|
|
|
4405
4550
|
return { success: false, isError: true, error: fitted(head, parts, notes, maxChars, 'stored') };
|
|
4406
4551
|
}
|
|
4407
4552
|
|
|
4408
|
-
|
|
4553
|
+
// ==========================================================================
|
|
4554
|
+
// Direct writes
|
|
4555
|
+
// ==========================================================================
|
|
4556
|
+
|
|
4557
|
+
/**
|
|
4558
|
+
* A direct mount's write of a whole file: its bytes staged beside it and
|
|
4559
|
+
* renamed over it (direct-write.ts), under the mount's turn, so the
|
|
4560
|
+
* module's own calls take turns. Answers a note when the file had other
|
|
4561
|
+
* hard links, which keep its old text, or why nothing was written. One
|
|
4562
|
+
* that lands is the caller's latest write on the mount (recordDirectWrite).
|
|
4563
|
+
* With `branch`, nothing is written once the store's branch is another,
|
|
4564
|
+
* checked with no turn of the event loop before the rename (writeBinary's).
|
|
4565
|
+
*/
|
|
4566
|
+
private async writeDirect(
|
|
4567
|
+
mount: MountState,
|
|
4568
|
+
relativePath: string,
|
|
4569
|
+
path: string,
|
|
4570
|
+
bytes: Buffer,
|
|
4571
|
+
agentName: string | undefined,
|
|
4572
|
+
branch?: string,
|
|
4573
|
+
): Promise<{ note: string | null } | { error: string; branchLeft?: true }> {
|
|
4574
|
+
return this.withMount(mount, async () => {
|
|
4575
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4576
|
+
let staged: Staged;
|
|
4577
|
+
try {
|
|
4578
|
+
staged = await stageContained(view, rootReal, relativePath, bytes, { makeDirectory: true });
|
|
4579
|
+
} catch (error) {
|
|
4580
|
+
return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
|
|
4581
|
+
}
|
|
4582
|
+
try {
|
|
4583
|
+
await directWriteTestSeam.afterStage?.([staged.path]);
|
|
4584
|
+
await placedStill(staged);
|
|
4585
|
+
// A write reads nothing first, so it replaces whatever is there.
|
|
4586
|
+
checkUnchanged(staged, null);
|
|
4587
|
+
if (branch !== undefined && this.getStore().currentBranch().name !== branch) {
|
|
4588
|
+
await discard(staged);
|
|
4589
|
+
return { error: BRANCH_LEFT, branchLeft: true };
|
|
4590
|
+
}
|
|
4591
|
+
renameInto(staged);
|
|
4592
|
+
} catch (error) {
|
|
4593
|
+
await discard(staged);
|
|
4594
|
+
return { error: `The write did nothing to ${path}: ${directReason(error)}.` };
|
|
4595
|
+
}
|
|
4596
|
+
this.recordDirectWrite(mount, agentName);
|
|
4597
|
+
return { note: linksNote(path, staged, 'write') };
|
|
4598
|
+
});
|
|
4599
|
+
}
|
|
4600
|
+
|
|
4601
|
+
/**
|
|
4602
|
+
* applyEdits on a direct mount, under the mount's turn, whose edits land
|
|
4603
|
+
* on disk or don't happen. Each attempt reads every file from disk as it
|
|
4604
|
+
* is now (directEditText), plans every edit against those bytes as on any
|
|
4605
|
+
* mount (planTargetEdits), stages every file beside itself, checks every
|
|
4606
|
+
* one unchanged since its read, and then renames each over its file in
|
|
4607
|
+
* turn (direct-write.ts). A file another writer changed between its read
|
|
4608
|
+
* and its check has the call read and planned again against its new
|
|
4609
|
+
* text, three attempts in all (DIRECT_EDIT_ATTEMPTS), so edits that no
|
|
4610
|
+
* longer apply to it, a file that changed at every attempt, or a file the
|
|
4611
|
+
* next read refuses as any read would (now past the size limit, say) are
|
|
4612
|
+
* refused, with nothing written. A rename that fails after others leaves
|
|
4613
|
+
* those written and the rest as they were, and the answer names each
|
|
4614
|
+
* file's outcome; nothing puts the written ones back.
|
|
4615
|
+
*/
|
|
4616
|
+
private async applyDirectEdits(
|
|
4617
|
+
tool: EditTool,
|
|
4618
|
+
mount: MountState,
|
|
4619
|
+
targets: EditTarget[],
|
|
4620
|
+
early: EditProblem[],
|
|
4621
|
+
editCount: number,
|
|
4622
|
+
maxChars: number,
|
|
4623
|
+
agentName: string | undefined,
|
|
4624
|
+
): Promise<ToolResult> {
|
|
4625
|
+
const changedMeanwhile = new Set<EditTarget>();
|
|
4626
|
+
return this.withMount(mount, async () => {
|
|
4627
|
+
for (let attempt = 1; ; attempt++) {
|
|
4628
|
+
const outcome = await this.attemptDirectEdits(tool, mount, targets, early, changedMeanwhile, editCount, maxChars, agentName);
|
|
4629
|
+
if (outcome !== 'changed') return outcome;
|
|
4630
|
+
if (attempt === DIRECT_EDIT_ATTEMPTS) {
|
|
4631
|
+
const changed = targets.filter((target) => changedMeanwhile.has(target)).map((target) => target.path);
|
|
4632
|
+
// Each attempt stops at the first file it finds changed, so these are
|
|
4633
|
+
// the files some attempt found changed, at most one per attempt.
|
|
4634
|
+
return {
|
|
4635
|
+
success: false,
|
|
4636
|
+
isError: true,
|
|
4637
|
+
error: tool === 'edit'
|
|
4638
|
+
? `${changed[0]} kept changing on disk while this edit was being applied (${DIRECT_EDIT_ATTEMPTS} attempts), so nothing was written. Read it again before retrying.`
|
|
4639
|
+
: `Another writer kept changing this call's files on disk while it was being applied: each of its ${DIRECT_EDIT_ATTEMPTS} attempts `
|
|
4640
|
+
+ `found one changed since its read (${listed(changed)}), so nothing was written. Read ${changed.length === 1 ? 'it' : 'them'} again before retrying.`,
|
|
4641
|
+
};
|
|
4642
|
+
}
|
|
4643
|
+
}
|
|
4644
|
+
});
|
|
4645
|
+
}
|
|
4646
|
+
|
|
4647
|
+
/**
|
|
4648
|
+
* One attempt of applyDirectEdits: its answer, or 'changed' when another
|
|
4649
|
+
* writer changed a file since its read, with nothing written. `early` are
|
|
4650
|
+
* the problems found before any file was read, which a refusal names with
|
|
4651
|
+
* the rest. The caller holds the mount's turn.
|
|
4652
|
+
*/
|
|
4653
|
+
private async attemptDirectEdits(
|
|
4654
|
+
tool: EditTool,
|
|
4655
|
+
mount: MountState,
|
|
4656
|
+
targets: EditTarget[],
|
|
4657
|
+
early: EditProblem[],
|
|
4658
|
+
changedMeanwhile: Set<EditTarget>,
|
|
4659
|
+
editCount: number,
|
|
4660
|
+
maxChars: number,
|
|
4661
|
+
agentName: string | undefined,
|
|
4662
|
+
): Promise<ToolResult | 'changed'> {
|
|
4663
|
+
const problems = [...early];
|
|
4664
|
+
const read = new Map<EditTarget, { bytes: Buffer; identity: FileIdentity }>();
|
|
4665
|
+
for (const target of targets) {
|
|
4666
|
+
const got = await this.directEditText(mount, target.relativePath, target.path);
|
|
4667
|
+
if ('changed' in got) {
|
|
4668
|
+
changedMeanwhile.add(target);
|
|
4669
|
+
return 'changed';
|
|
4670
|
+
}
|
|
4671
|
+
if ('error' in got) problems.push({ kind: 'file', edits: target.edits, path: target.path, text: got.error, whole: true });
|
|
4672
|
+
else read.set(target, got);
|
|
4673
|
+
}
|
|
4674
|
+
// Two paths that reach one file, through a link this mount follows, or
|
|
4675
|
+
// as two hard links (a disk that folds case has its two spellings made
|
|
4676
|
+
// one path by the parse, short of a directory link on the way, past
|
|
4677
|
+
// which they're refused here as one file named twice): each
|
|
4678
|
+
// would be planned against the same text and renamed over the same file
|
|
4679
|
+
// or split from it, so one's edits would be lost or kept apart from the
|
|
4680
|
+
// other's. They're refused. One file read twice has one identity here
|
|
4681
|
+
// unless a writer changed it between the reads, and the check before the
|
|
4682
|
+
// renames then sees that and reads again.
|
|
4683
|
+
const byIdentity = new Map<string, EditTarget>();
|
|
4684
|
+
for (const [target, { identity }] of read) {
|
|
4685
|
+
const key = `${identity.dev}:${identity.ino}:${identity.size}:${identity.mtimeNs}:${identity.ctimeNs}`;
|
|
4686
|
+
const other = byIdentity.get(key);
|
|
4687
|
+
if (!other) {
|
|
4688
|
+
byIdentity.set(key, target);
|
|
4689
|
+
continue;
|
|
4690
|
+
}
|
|
4691
|
+
problems.push({
|
|
4692
|
+
kind: 'file',
|
|
4693
|
+
edits: [...other.edits, ...target.edits].sort((a, b) => a - b),
|
|
4694
|
+
path: other.path,
|
|
4695
|
+
text: `${other.path} and ${target.path} are one file, reached through a link or as two hard links; name it one way, so its edits are matched in one text`,
|
|
4696
|
+
whole: true,
|
|
4697
|
+
});
|
|
4698
|
+
}
|
|
4699
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4700
|
+
const hints = hintBudget();
|
|
4701
|
+
// As much as the largest file the call names holds, at least, so the
|
|
4702
|
+
// budget is the same whatever order its files are planned in.
|
|
4703
|
+
let largest = maxSize;
|
|
4704
|
+
for (const { bytes } of read.values()) largest = Math.max(largest, bytes.length);
|
|
4705
|
+
const counts = countBudget(largest);
|
|
4706
|
+
const plans: Array<{ target: EditTarget; plan: FilePlan }> = [];
|
|
4707
|
+
for (const target of targets) {
|
|
4708
|
+
const got = read.get(target);
|
|
4709
|
+
if (!got) continue;
|
|
4710
|
+
const plan = planTargetEdits(tool, target, got.bytes, hints, maxSize, counts, problems);
|
|
4711
|
+
if (plan !== null) plans.push({ target, plan });
|
|
4712
|
+
}
|
|
4713
|
+
// A file read again for another writer's change says so, whichever way
|
|
4714
|
+
// its edits went.
|
|
4715
|
+
const reread = (target: EditTarget, verb: string): string | null => !changedMeanwhile.has(target) ? null
|
|
4716
|
+
: `Disk's copy${tool === 'edit' ? '' : ` of ${target.path}`} changed while this call was being applied, so it was read again, `
|
|
4717
|
+
+ `and ${tool === 'edit' ? 'the edit' : editList(target.edits)} ${verb} its new text.`;
|
|
4718
|
+
const refuse = (found: EditProblem[]): ToolResult => ({
|
|
4719
|
+
success: false,
|
|
4720
|
+
isError: true,
|
|
4721
|
+
error: refusalText(tool, found, targets.map((target) => reread(target, 'looked in')).filter((note): note is string => note !== null), maxChars, 'wrote'),
|
|
4722
|
+
});
|
|
4723
|
+
if (problems.length) return refuse(problems);
|
|
4724
|
+
|
|
4725
|
+
const { view, rootReal } = await this.diskView(mount);
|
|
4726
|
+
const staged: Array<{ target: EditTarget; staged: Staged }> = [];
|
|
4727
|
+
const discardAll = async (from = 0): Promise<void> => {
|
|
4728
|
+
for (const { staged: file } of staged.slice(from)) await discard(file);
|
|
4729
|
+
};
|
|
4730
|
+
// A refusal before any file is renamed says so where the call names several.
|
|
4731
|
+
const nothingWritten = (target: EditTarget, error: unknown): ToolResult => refuse([{
|
|
4732
|
+
kind: 'file',
|
|
4733
|
+
edits: target.edits,
|
|
4734
|
+
path: target.path,
|
|
4735
|
+
text: `${target.path}: ${directReason(error)}, so ${tool === 'edit' ? 'the edit' : editList(target.edits)} changed nothing.`
|
|
4736
|
+
+ (tool === 'edit_many' && plans.length > 1 ? ' No file was written.' : ''),
|
|
4737
|
+
whole: true,
|
|
4738
|
+
}]);
|
|
4739
|
+
for (const { target, plan } of plans) {
|
|
4740
|
+
try {
|
|
4741
|
+
staged.push({ target, staged: await stageContained(view, rootReal, target.relativePath, plan.bytes) });
|
|
4742
|
+
} catch (error) {
|
|
4743
|
+
await discardAll();
|
|
4744
|
+
if (error instanceof FileChanged) {
|
|
4745
|
+
changedMeanwhile.add(target);
|
|
4746
|
+
return 'changed';
|
|
4747
|
+
}
|
|
4748
|
+
return nothingWritten(target, error);
|
|
4749
|
+
}
|
|
4750
|
+
}
|
|
4751
|
+
try {
|
|
4752
|
+
await directWriteTestSeam.afterStage?.(staged.map(({ staged: file }) => file.path));
|
|
4753
|
+
} catch (error) {
|
|
4754
|
+
await discardAll();
|
|
4755
|
+
throw error;
|
|
4756
|
+
}
|
|
4757
|
+
for (const { target, staged: file } of staged) {
|
|
4758
|
+
try {
|
|
4759
|
+
await placedStill(file);
|
|
4760
|
+
} catch (error) {
|
|
4761
|
+
await discardAll();
|
|
4762
|
+
return nothingWritten(target, error);
|
|
4763
|
+
}
|
|
4764
|
+
}
|
|
4765
|
+
// Every file is checked, then every file renamed, with no turn of the
|
|
4766
|
+
// event loop between: a change to any of them before its check is seen
|
|
4767
|
+
// before any is written, and one landing between its check and the last
|
|
4768
|
+
// rename isn't (checkUnchanged).
|
|
4769
|
+
for (const { target, staged: file } of staged) {
|
|
4770
|
+
try {
|
|
4771
|
+
checkUnchanged(file, read.get(target)!);
|
|
4772
|
+
} catch (error) {
|
|
4773
|
+
await discardAll();
|
|
4774
|
+
if (error instanceof FileChanged) {
|
|
4775
|
+
changedMeanwhile.add(target);
|
|
4776
|
+
return 'changed';
|
|
4777
|
+
}
|
|
4778
|
+
return nothingWritten(target, error);
|
|
4779
|
+
}
|
|
4780
|
+
}
|
|
4781
|
+
for (const [i, { target, staged: file }] of staged.entries()) {
|
|
4782
|
+
try {
|
|
4783
|
+
renameInto(file);
|
|
4784
|
+
} catch (error) {
|
|
4785
|
+
if (i === 0) {
|
|
4786
|
+
await discardAll();
|
|
4787
|
+
return nothingWritten(target, error);
|
|
4788
|
+
}
|
|
4789
|
+
// Read with no turn of the event loop since the last rename.
|
|
4790
|
+
this.recordDirectWrite(mount, agentName);
|
|
4791
|
+
await discardAll(i);
|
|
4792
|
+
return { success: false, isError: true, error: this.directEditsPartway(staged, i, error, maxChars) };
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
this.recordDirectWrite(mount, agentName);
|
|
4796
|
+
return editReceipt(tool, staged.map(({ target, staged: file }) => ({
|
|
4797
|
+
path: target.path,
|
|
4798
|
+
plan: plans.find((p) => p.target === target)!.plan,
|
|
4799
|
+
notes: [reread(target, 'applied to'), linksNote(target.path, file, tool)].filter((note): note is string => note !== null),
|
|
4800
|
+
modeKept: null,
|
|
4801
|
+
})), editCount, maxChars);
|
|
4802
|
+
}
|
|
4803
|
+
|
|
4804
|
+
/**
|
|
4805
|
+
* The answer to an edit_many call on a direct mount whose rename of its
|
|
4806
|
+
* `failed`th file failed after the ones before it were written: what was
|
|
4807
|
+
* written, the failure, and that nothing puts them back, then each file's
|
|
4808
|
+
* outcome, within `maxChars`, with the hard links a written one split
|
|
4809
|
+
* from, as a full answer names them (linksNote).
|
|
4810
|
+
*/
|
|
4811
|
+
private directEditsPartway(staged: Array<{ target: EditTarget; staged: Staged }>, failed: number, error: unknown, maxChars: number): string {
|
|
4812
|
+
// The failed file is named among the outcomes, so the head stays as
|
|
4813
|
+
// long as its reason, whatever the paths' lengths.
|
|
4814
|
+
const head = `The edit_many call wrote ${failed} of its ${staged.length} files before the next one's write failed: ${directReason(error)}. `
|
|
4815
|
+
+ "Nothing puts the written files back, so finish or revert them: in each written file, each of its edits replaced its oldString with its newString. Each file's outcome:";
|
|
4816
|
+
const parts = staged.map(({ target }, i) => `${target.path}: ${i < failed ? 'written' : i === failed ? 'not written, as it was (its write failed)' : 'not written, as it was'}`);
|
|
4817
|
+
const notes = staged.slice(0, failed).map(({ target, staged: file }) => linksNote(target.path, file, 'edit_many')).filter((note): note is string => note !== null);
|
|
4818
|
+
return fitted(head, parts, notes, maxChars, 'written');
|
|
4819
|
+
}
|
|
4820
|
+
|
|
4821
|
+
/**
|
|
4822
|
+
* A direct mount's file as an edit reads it: its bytes on disk as they are
|
|
4823
|
+
* now, through the mount's boundary as read reaches them
|
|
4824
|
+
* (openContainedFile), with the file's identity when the read began, which
|
|
4825
|
+
* the write checks it against just before its rename. A file changed while
|
|
4826
|
+
* it was read is `changed`, so the call reads it again. What the
|
|
4827
|
+
* workspace doesn't show as text, it doesn't edit either: a file over the
|
|
4828
|
+
* mount's size limit, and a binary one.
|
|
4829
|
+
*/
|
|
4830
|
+
private async directEditText(
|
|
4831
|
+
mount: MountState,
|
|
4832
|
+
relativePath: string,
|
|
4833
|
+
path: string,
|
|
4834
|
+
): Promise<{ bytes: Buffer; identity: FileIdentity } | { error: string } | { changed: true }> {
|
|
4835
|
+
const maxSize = mount.config.maxFileSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
4836
|
+
const over = `${path} is over the size limit (${maxSize} bytes), so the workspace doesn't edit it.`;
|
|
4837
|
+
let opened: Awaited<ReturnType<WorkspaceModule['openContainedFile']>>;
|
|
4838
|
+
try {
|
|
4839
|
+
opened = await this.openContainedFile(mount, relativePath, path, (fileStat) => {
|
|
4840
|
+
if (fileStat.size > maxSize) throw new Error(over);
|
|
4841
|
+
});
|
|
4842
|
+
} catch (err) {
|
|
4843
|
+
if (err instanceof WorkspaceReadError && err.code === 'changed') return { changed: true };
|
|
4844
|
+
return { error: containedRefusal(err, path) };
|
|
4845
|
+
}
|
|
4846
|
+
try {
|
|
4847
|
+
const before = identityOf(await opened.handle.stat({ bigint: true }));
|
|
4848
|
+
const { bytes, more } = await readPrefix(opened.handle, maxSize, opened.fileStat.size);
|
|
4849
|
+
if (more) return { error: over };
|
|
4850
|
+
if (looksBinary(bytes)) return { error: `${path} is binary, so the workspace doesn't edit it as text.` };
|
|
4851
|
+
if (!sameIdentity(before, identityOf(await opened.handle.stat({ bigint: true })))) return { changed: true };
|
|
4852
|
+
return { bytes, identity: before };
|
|
4853
|
+
} catch (err) {
|
|
4854
|
+
const errno = errnoOf(err);
|
|
4855
|
+
return { error: `Unable to read file: ${path}${errno ? ` (${errno})` : ''}` };
|
|
4856
|
+
} finally {
|
|
4857
|
+
await opened.handle.close();
|
|
4858
|
+
}
|
|
4859
|
+
}
|
|
4860
|
+
|
|
4861
|
+
/**
|
|
4862
|
+
* A direct mount's deletion: the file the read tools reach, by their
|
|
4863
|
+
* refusals (openContainedFile), unlinked through the mount's boundary as
|
|
4864
|
+
* a push's deletion unlinks one (unlinkContained). A followed link is the
|
|
4865
|
+
* entry removed, as there. One that lands is the caller's latest write on
|
|
4866
|
+
* the mount (recordDirectWrite).
|
|
4867
|
+
*/
|
|
4868
|
+
private async deleteDirect(mount: MountState, relativePath: string, path: string, agentName: string | undefined): Promise<ToolResult> {
|
|
4869
|
+
return this.withMount(mount, async (): Promise<ToolResult> => {
|
|
4870
|
+
// One root for the call, by its identity as the call starts: the
|
|
4871
|
+
// open's checks and the unlink's own refuse any other directory there,
|
|
4872
|
+
// in ROOT_MOVED's words, so a root replaced after this stat is refused
|
|
4873
|
+
// rather than taken, but in the instant between the unlink's last
|
|
4874
|
+
// check and the unlink, which Node can't close (effects.ts's header).
|
|
4875
|
+
// One that can't be read now is refused here.
|
|
4876
|
+
const disk = await this.diskView(mount);
|
|
4877
|
+
let rootStat: BigIntStats;
|
|
4878
|
+
try {
|
|
4879
|
+
rootStat = await stat(disk.rootReal, { bigint: true });
|
|
4880
|
+
} catch (err) {
|
|
4881
|
+
const code = errnoOf(err);
|
|
4882
|
+
const why = code === 'ENOENT' || code === 'ENOTDIR'
|
|
4883
|
+
? `the mount root is gone (${code}), and calling again is refused the same way until whoever runs the host puts a directory back at the mount's path`
|
|
4884
|
+
: code === 'EACCES' || code === 'EPERM'
|
|
4885
|
+
? `the mount root can't be read (${code}), and calling again is refused the same way until whoever runs the host makes it readable`
|
|
4886
|
+
: `the mount root can't be read (${code ?? String(err)})`;
|
|
4887
|
+
return { success: false, isError: true, error: `The delete did nothing to ${path}: ${why}.` };
|
|
4888
|
+
}
|
|
4889
|
+
const view = { ...disk.view, rootIdentity: { dev: String(rootStat.dev), ino: String(rootStat.ino) } };
|
|
4890
|
+
const { rootReal } = disk;
|
|
4891
|
+
try {
|
|
4892
|
+
const opened = await this.openContainedFile(mount, relativePath, path);
|
|
4893
|
+
await opened.handle.close();
|
|
4894
|
+
} catch (err) {
|
|
4895
|
+
// The open's own words for a replaced root speak of reading.
|
|
4896
|
+
if (err instanceof WorkspaceReadError && err.message.endsWith(DIRECT_ROOT_REPLACED)) {
|
|
4897
|
+
return { success: false, isError: true, error: `The delete did nothing to ${path}: ${ROOT_MOVED}.` };
|
|
4898
|
+
}
|
|
4899
|
+
return { success: false, isError: true, error: containedRefusal(err, path) };
|
|
4900
|
+
}
|
|
4901
|
+
// The unlink is awaited, unlike a write's rename, so where the head
|
|
4902
|
+
// was just before it says whether the head moved while it ran.
|
|
4903
|
+
const since = this.positionNow();
|
|
4904
|
+
try {
|
|
4905
|
+
await unlinkContained(view, rootReal, relativePath, { kind: 'any' });
|
|
4906
|
+
} catch (error) {
|
|
4907
|
+
// A failure after the unlink reached the file's entry can't say
|
|
4908
|
+
// whether the file is gone, so it's taken as the agent's write too.
|
|
4909
|
+
if (error instanceof EffectFailed && error.touched) {
|
|
4910
|
+
this.recordDirectWrite(mount, agentName, since);
|
|
4911
|
+
return { success: false, isError: true, error: `The delete of ${path} failed after it reached the file, so the file may be gone: ${directReason(error)}. ls shows whether it is.` };
|
|
4912
|
+
}
|
|
4913
|
+
return { success: false, isError: true, error: `The delete did nothing to ${path}: ${directReason(error)}.` };
|
|
4914
|
+
}
|
|
4915
|
+
this.recordDirectWrite(mount, agentName, since);
|
|
4916
|
+
return { success: true, data: { path, deleted: true }, keepWhenWithheld: ['path', 'deleted'] };
|
|
4917
|
+
});
|
|
4918
|
+
}
|
|
4919
|
+
|
|
4920
|
+
/**
|
|
4921
|
+
* After a direct write lands: the agent's latest write on the mount, P,
|
|
4922
|
+
* moves to it, where the agent's history holds P or the notice of its
|
|
4923
|
+
* departure from P, N (historyHoldsWrites), and N clears. Otherwise P
|
|
4924
|
+
* stays where it is: the head moved away from it under the call's turn,
|
|
4925
|
+
* and the next start's notice covers what it left (headMoveNotice). P is
|
|
4926
|
+
* the branch and the store's head as it is now, read with no turn of the
|
|
4927
|
+
* event loop since the write, so no record that lands after the write is
|
|
4928
|
+
* taken for one before it. A deletion, whose unlink is awaited, passes
|
|
4929
|
+
* where the head was just before its unlink (`since`): where the branch
|
|
4930
|
+
* changed meanwhile, the head moved under the call, and P is set there,
|
|
4931
|
+
* so the next start judges the deletion by the branch its turn was on,
|
|
4932
|
+
* unless the history doesn't hold P already, whose notice is owed. A write no agent
|
|
4933
|
+
* made tells no one. The store's reads here come after the write landed,
|
|
4934
|
+
* so one that throws is logged and P stays, as for a history that can't
|
|
4935
|
+
* be read, and the write still answers that it landed.
|
|
4936
|
+
*/
|
|
4937
|
+
private recordDirectWrite(mount: MountState, agentName: string | undefined, since?: DirectPosition | null): void {
|
|
4938
|
+
if (agentName === undefined) return;
|
|
4939
|
+
try {
|
|
4940
|
+
const store = this.getStore();
|
|
4941
|
+
let heads = this.directHeads.get(mount.config.name);
|
|
4942
|
+
if (!heads) this.directHeads.set(mount.config.name, (heads = new Map()));
|
|
4943
|
+
const head = heads.get(agentName);
|
|
4944
|
+
const branchId = store.currentBranch().id;
|
|
4945
|
+
if (since && since.branchId !== branchId) {
|
|
4946
|
+
if (!head || this.historyHoldsWrites(agentName, head) === true) heads.set(agentName, { ...since, notice: null });
|
|
4947
|
+
return;
|
|
4948
|
+
}
|
|
4949
|
+
if (head && this.historyHoldsWrites(agentName, head) !== true) return;
|
|
4950
|
+
heads.set(agentName, { branchId, after: store.currentSequence(), notice: null });
|
|
4951
|
+
} catch (err) {
|
|
4952
|
+
console.error('[workspace] could not record a direct write\'s position:', err);
|
|
4953
|
+
}
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
/** The store's branch and head now, or null where the store can't say. */
|
|
4957
|
+
private positionNow(): DirectPosition | null {
|
|
4958
|
+
try {
|
|
4959
|
+
const store = this.getStore();
|
|
4960
|
+
return { branchId: store.currentBranch().id, after: store.currentSequence() };
|
|
4961
|
+
} catch {
|
|
4962
|
+
return null;
|
|
4963
|
+
}
|
|
4964
|
+
}
|
|
4965
|
+
|
|
4966
|
+
/**
|
|
4967
|
+
* Whether an agent's history holds its latest direct write on a mount
|
|
4968
|
+
* (holdsPosition), or the notice of its departure from it, judged by the
|
|
4969
|
+
* notice's own presence there, through the lookup the framework hands the
|
|
4970
|
+
* module (useHistory); null where that lookup threw, which is logged. A
|
|
4971
|
+
* history that can't be read decides nothing: a write takes it as not
|
|
4972
|
+
* held, so its position stays and the notice stays owed, and a start
|
|
4973
|
+
* gives no notice, which may not be true of a history it can't read, and
|
|
4974
|
+
* leaves the next start to decide (headMoveNotice).
|
|
4975
|
+
*/
|
|
4976
|
+
private historyHoldsWrites(agentName: string, head: DirectHead): boolean | null {
|
|
4977
|
+
if (this.holdsPosition(head)) return true;
|
|
4978
|
+
if (head.notice === null) return false;
|
|
4979
|
+
try {
|
|
4980
|
+
return this.historyLookup?.(agentName, head.notice) === true;
|
|
4981
|
+
} catch (err) {
|
|
4982
|
+
console.error('[workspace] history lookup for the head-move notice failed:', err);
|
|
4983
|
+
return null;
|
|
4984
|
+
}
|
|
4985
|
+
}
|
|
4986
|
+
|
|
4987
|
+
/**
|
|
4988
|
+
* Whether the store's current branch holds what came before a direct
|
|
4989
|
+
* write: it's the write's branch, or continues it with every fork point on
|
|
4990
|
+
* the way after `after`, the store's head when the write landed. A fork at
|
|
4991
|
+
* exactly `after` doesn't: an undo forks at its turn's sequenceBefore,
|
|
4992
|
+
* which is the head at the write when nothing was recorded in the turn
|
|
4993
|
+
* before it. A missing branch or one without fork metadata holds nothing,
|
|
4994
|
+
* as for the materialize guard (isLinearContinuation).
|
|
4995
|
+
*/
|
|
4996
|
+
private holdsPosition({ branchId, after }: DirectHead): boolean {
|
|
4997
|
+
const store = this.getStore();
|
|
4998
|
+
const current = store.currentBranch();
|
|
4999
|
+
if (current.id === branchId) return true;
|
|
5000
|
+
const byId = new Map(store.listBranches().map((b) => [b.id, b]));
|
|
5001
|
+
const visited = new Set<string>();
|
|
5002
|
+
let cursor = byId.get(current.id);
|
|
5003
|
+
while (cursor && !visited.has(cursor.id)) {
|
|
5004
|
+
visited.add(cursor.id);
|
|
5005
|
+
if (cursor.id === branchId) return true;
|
|
5006
|
+
if (cursor.parentId === undefined || cursor.branchPoint === undefined || cursor.branchPoint <= after) return false;
|
|
5007
|
+
cursor = byId.get(cursor.parentId);
|
|
5008
|
+
}
|
|
5009
|
+
return false;
|
|
5010
|
+
}
|
|
5011
|
+
|
|
5012
|
+
/**
|
|
5013
|
+
* The framework's check at every start of an agent's turn, opening,
|
|
5014
|
+
* continuing or resuming: the notice due for each mount the agent wrote
|
|
5015
|
+
* direct where its history holds neither its latest write there nor the
|
|
5016
|
+
* notice of its departure from it (historyHoldsWrites), with those
|
|
5017
|
+
* mounts' names; or null, as on a head that hasn't moved. The framework
|
|
5018
|
+
* stores the text in the agent's history before the turn's compile and
|
|
5019
|
+
* hands its message id back (headMoveNoticeStored).
|
|
5020
|
+
*/
|
|
5021
|
+
headMoveNotice(agentName: string): { text: string; mounts: string[] } | null {
|
|
5022
|
+
if (!this.store) return null;
|
|
5023
|
+
const due: string[] = [];
|
|
5024
|
+
// Every mount where the agent wrote direct, whether it runs direct this
|
|
5025
|
+
// session or as 'chronicle': the writes are on disk either way.
|
|
5026
|
+
for (const name of this.mounts.keys()) {
|
|
5027
|
+
const head = this.directHeads.get(name)?.get(agentName);
|
|
5028
|
+
if (!head) continue;
|
|
5029
|
+
const held = this.historyHoldsWrites(agentName, head);
|
|
5030
|
+
// A history that can't be read decides nothing at this start.
|
|
5031
|
+
if (held === null) return null;
|
|
5032
|
+
if (!held) due.push(name);
|
|
5033
|
+
}
|
|
5034
|
+
return due.length ? { text: headMoveText(due), mounts: due } : null;
|
|
5035
|
+
}
|
|
5036
|
+
|
|
5037
|
+
/** The id of the message headMoveNotice's text was stored as, N, for each mount it named. */
|
|
5038
|
+
headMoveNoticeStored(agentName: string, mounts: readonly string[], messageId: string): void {
|
|
5039
|
+
for (const name of mounts) {
|
|
5040
|
+
const head = this.directHeads.get(name)?.get(agentName);
|
|
5041
|
+
if (head) head.notice = messageId;
|
|
5042
|
+
}
|
|
5043
|
+
}
|
|
5044
|
+
|
|
5045
|
+
/**
|
|
5046
|
+
* How the module asks whether an agent's history holds a message, as the
|
|
5047
|
+
* framework judges its own notices (`cm.getMessage(id) !== null`): handed
|
|
5048
|
+
* over once, when the module is added. Without it, no notice is held.
|
|
5049
|
+
*/
|
|
5050
|
+
useHistory(lookup: (agentName: string, messageId: string) => boolean): void {
|
|
5051
|
+
this.historyLookup = lookup;
|
|
5052
|
+
}
|
|
5053
|
+
|
|
5054
|
+
private async handleDelete(input: DeleteInput, callerAgentName?: string): Promise<ToolResult> {
|
|
4409
5055
|
const { mount, relativePath, path } = await this.parseFilePath(input.path);
|
|
4410
5056
|
input = { ...input, path };
|
|
4411
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
5057
|
+
const refusal = writeRefusal(mount);
|
|
5058
|
+
if (refusal) return { success: false, error: refusal, isError: true };
|
|
5059
|
+
if (mount.direct) return this.deleteDirect(mount, relativePath, input.path, callerAgentName);
|
|
4414
5060
|
|
|
4415
5061
|
const store = this.getStore();
|
|
4416
5062
|
let committed = false;
|
|
@@ -4458,7 +5104,9 @@ export class WorkspaceModule implements Module {
|
|
|
4458
5104
|
const mounts = [...this.mounts.entries()].map(([name, m]) => ({
|
|
4459
5105
|
name,
|
|
4460
5106
|
path: m.config.path,
|
|
4461
|
-
mode
|
|
5107
|
+
// A mount configured direct says its mode where its config names
|
|
5108
|
+
// none, so a writable one shows as writable.
|
|
5109
|
+
mode: m.config.backend === 'direct' ? m.config.mode ?? 'read-write' : m.config.mode,
|
|
4462
5110
|
...(m.config.backend === 'direct' ? { backend: m.direct ? 'direct' : 'chronicle', ignore: this.ignoreOf(m) } : {}),
|
|
4463
5111
|
...(m.config.include ? { include: [...m.config.include] } : {}),
|
|
4464
5112
|
}));
|
|
@@ -4619,15 +5267,26 @@ export class WorkspaceModule implements Module {
|
|
|
4619
5267
|
// starts there: past the walk's cap, a walk of the whole scope can stop
|
|
4620
5268
|
// before it gets there. In the mount a pattern's first segment names, the
|
|
4621
5269
|
// pattern is read mount-prefixed (prefixedGlob), from its rest's directory
|
|
4622
|
-
// within `path`.
|
|
5270
|
+
// within `path`. A `path` that directory can't overlap leaves no file the
|
|
5271
|
+
// pattern can match, so the glob is refused, as grep's is, saying how
|
|
5272
|
+
// the pattern is read, rather than answered with no matches; but a path
|
|
5273
|
+
// in another mount's directory is that mount's, and is said so, as grep
|
|
5274
|
+
// says it. Only a `path` can do either, and it names one mount, so
|
|
5275
|
+
// nothing has been searched yet.
|
|
4623
5276
|
const patternDir = literalDirOf(input.pattern);
|
|
4624
|
-
for (const { mount, relativePath } of mountsToSearch) {
|
|
5277
|
+
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
4625
5278
|
const prefixed = prefixedGlob(input.pattern, mount.config.name);
|
|
4626
5279
|
let start: string;
|
|
4627
5280
|
let matched: (path: string) => boolean;
|
|
4628
5281
|
if (prefixed) {
|
|
4629
5282
|
const dir = deeperOf(relativePath, prefixed.dir);
|
|
4630
|
-
if (dir === null)
|
|
5283
|
+
if (dir === null) {
|
|
5284
|
+
if (this.holderOf(mount.config.name, relativePath, false, moved)) {
|
|
5285
|
+
incomplete.push({ path: `${mount.config.name}/${relativePath}`, reason: this.otherMountNote(mount.config.name, relativePath, 'list', moved) });
|
|
5286
|
+
continue;
|
|
5287
|
+
}
|
|
5288
|
+
return { success: false, error: await this.disjointRefusal('glob', input.path!, input.pattern, true, mount, relativePath, root), isError: true };
|
|
5289
|
+
}
|
|
4631
5290
|
start = await this.walkStart(mount, relativePath, dir === relativePath ? '' : dir.slice(relativePath ? relativePath.length + 1 : 0));
|
|
4632
5291
|
matched = (path: string) => prefixed.regex.test(path);
|
|
4633
5292
|
} else {
|
|
@@ -4662,11 +5321,19 @@ export class WorkspaceModule implements Module {
|
|
|
4662
5321
|
}
|
|
4663
5322
|
|
|
4664
5323
|
const fileGlob = input.glob ? globToRegex(input.glob) : null;
|
|
4665
|
-
// The filter matches mount-relative paths,
|
|
4666
|
-
//
|
|
4667
|
-
//
|
|
4668
|
-
// mount
|
|
5324
|
+
// The filter matches mount-relative paths, from the mount's root whatever
|
|
5325
|
+
// the `path`, so every file it lets through lies beneath its literal
|
|
5326
|
+
// directory, and the walk starts at the deeper of that and `path`. In the
|
|
5327
|
+
// mount a filter's first segment names, its mount-prefixed reading takes
|
|
5328
|
+
// the filter's place (below). One with no "/" also matches a file's name,
|
|
5329
|
+
// wherever the file lies, as ripgrep's --glob does: "*.ts" lets every .ts
|
|
5330
|
+
// file through, where tested against the path alone it let through only
|
|
5331
|
+
// those at the mount's root. The path is still tried, so a "**" in such a
|
|
5332
|
+
// filter spans directories as it did, and nothing it let through before
|
|
5333
|
+
// is lost. A filter read mount-prefixed always holds a "/" after the
|
|
5334
|
+
// mount's name, so it never takes the name test.
|
|
4669
5335
|
const filterDir = input.glob ? literalDirOf(input.glob) : '';
|
|
5336
|
+
const byName = fileGlob !== null && !input.glob!.includes('/');
|
|
4670
5337
|
const contextBefore = input.contextBefore ?? 0;
|
|
4671
5338
|
const contextAfter = input.contextAfter ?? 0;
|
|
4672
5339
|
|
|
@@ -4683,12 +5350,30 @@ export class WorkspaceModule implements Module {
|
|
|
4683
5350
|
}
|
|
4684
5351
|
|
|
4685
5352
|
type GrepMatch = { line: number; text: string; context?: string[] };
|
|
4686
|
-
|
|
5353
|
+
type Searched = { lineEndings?: 'CRLF' | 'mixed'; matches: GrepMatch[] };
|
|
5354
|
+
const results: Array<{ file: string; state?: string; version: GrepText['version'] } & Searched> = [];
|
|
4687
5355
|
const skipped: GrepSkip[] = [];
|
|
4688
5356
|
const incomplete: Array<{ path: string; reason: string }> = [];
|
|
4689
5357
|
|
|
4690
|
-
|
|
4691
|
-
|
|
5358
|
+
// Lines as read shows them (fileText): a CRLF end's CR is left out, for
|
|
5359
|
+
// matching as well as display, unless the line's own text ends in a CR, so
|
|
5360
|
+
// `foo$` finds a CRLF file's line, as it does under ripgrep's --crlf, and
|
|
5361
|
+
// the line copies into an edit as read's do. ripgrep's --crlf still prints
|
|
5362
|
+
// the line with its CR.
|
|
5363
|
+
//
|
|
5364
|
+
// Whether a file has CRLF ends or a BOM is a fact about the file, not a
|
|
5365
|
+
// line, so each searched file that has either is kept here by its path,
|
|
5366
|
+
// for the note on a pattern that names a CR or U+FEFF to name it. A
|
|
5367
|
+
// conflict's disk version is kept, for each, only where the workspace's
|
|
5368
|
+
// copy of its path doesn't have it, and is named as that version, since
|
|
5369
|
+
// read doesn't show it.
|
|
5370
|
+
const crlfFiles = new Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>();
|
|
5371
|
+
const bomFiles = new Map<string, GrepText['version']>();
|
|
5372
|
+
const search = (text: GrepText): Searched => {
|
|
5373
|
+
const { lines, crlfEnds, lfEnds, bom } = fileText(text.bytes);
|
|
5374
|
+
const lineEndings = crlfEnds > 0 ? (lfEnds > 0 ? 'mixed' : 'CRLF') : undefined;
|
|
5375
|
+
if (lineEndings !== undefined && !crlfFiles.has(text.file)) crlfFiles.set(text.file, { ends: lineEndings, version: text.version });
|
|
5376
|
+
if (bom && !bomFiles.has(text.file)) bomFiles.set(text.file, text.version);
|
|
4692
5377
|
const found: GrepMatch[] = [];
|
|
4693
5378
|
for (let i = 0; i < lines.length; i++) {
|
|
4694
5379
|
if (regex.test(lines[i])) {
|
|
@@ -4701,7 +5386,7 @@ export class WorkspaceModule implements Module {
|
|
|
4701
5386
|
found.push(match);
|
|
4702
5387
|
}
|
|
4703
5388
|
}
|
|
4704
|
-
return found;
|
|
5389
|
+
return { ...(lineEndings !== undefined ? { lineEndings } : {}), matches: found };
|
|
4705
5390
|
};
|
|
4706
5391
|
|
|
4707
5392
|
for (const { mount, relativePath, moved, root } of mountsToSearch) {
|
|
@@ -4715,19 +5400,26 @@ export class WorkspaceModule implements Module {
|
|
|
4715
5400
|
}
|
|
4716
5401
|
// In the mount a filter's first segment names, the filter is read
|
|
4717
5402
|
// mount-prefixed (prefixedGlob), from its rest's directory. A `path` and
|
|
4718
|
-
// a filter whose directories
|
|
4719
|
-
// let through, so
|
|
5403
|
+
// a filter whose directories, in the reading this mount takes, don't
|
|
5404
|
+
// overlap leave no file the filter can let through, so the grep is
|
|
5405
|
+
// refused, saying how the filter is read, rather than answered with no
|
|
5406
|
+
// matches: written relative to the `path`, as glob's pattern is, it
|
|
5407
|
+
// names nothing there. Only a `path` can do this, and it names one
|
|
5408
|
+
// mount, so nothing has been searched yet.
|
|
4720
5409
|
const prefixed = input.glob ? prefixedGlob(input.glob, mount.config.name) : null;
|
|
4721
5410
|
const deeper = deeperOf(relativePath, prefixed ? prefixed.dir : filterDir);
|
|
4722
|
-
if (deeper === null)
|
|
5411
|
+
if (deeper === null) {
|
|
5412
|
+
return { success: false, error: await this.disjointRefusal('grep', input.path!, input.glob!, prefixed !== null, mount, relativePath, root), isError: true };
|
|
5413
|
+
}
|
|
4723
5414
|
const start = await this.walkStart(mount, relativePath, deeper === relativePath ? '' : deeper.slice(relativePath ? relativePath.length + 1 : 0));
|
|
4724
5415
|
const searched = (text: GrepText): void => {
|
|
4725
|
-
const
|
|
4726
|
-
if (matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version,
|
|
5416
|
+
const hits = search(text);
|
|
5417
|
+
if (hits.matches.length > 0) results.push({ file: text.file, ...(text.state !== undefined ? { state: text.state } : {}), version: text.version, ...hits });
|
|
4727
5418
|
};
|
|
4728
5419
|
const kept = prefixed
|
|
4729
5420
|
? (path: string): boolean => prefixed.regex.test(path)
|
|
4730
|
-
: (path: string): boolean => fileGlob === null || fileGlob.test(path)
|
|
5421
|
+
: (path: string): boolean => fileGlob === null || fileGlob.test(path)
|
|
5422
|
+
|| (byName && fileGlob.test(path.slice(path.lastIndexOf('/') + 1)));
|
|
4731
5423
|
const found = mount.direct
|
|
4732
5424
|
? await this.textsFromDisk(mount, relativePath, root, start, kept, searched)
|
|
4733
5425
|
: await this.textsFromStore(mount, relativePath, start, kept, searched);
|
|
@@ -4735,16 +5427,45 @@ export class WorkspaceModule implements Module {
|
|
|
4735
5427
|
incomplete.push(...found.incomplete);
|
|
4736
5428
|
}
|
|
4737
5429
|
|
|
4738
|
-
|
|
4739
|
-
|
|
4740
|
-
|
|
4741
|
-
|
|
4742
|
-
|
|
4743
|
-
|
|
4744
|
-
|
|
4745
|
-
|
|
4746
|
-
},
|
|
5430
|
+
const answer: GrepAnswer = {
|
|
5431
|
+
pattern: input.pattern,
|
|
5432
|
+
results,
|
|
5433
|
+
totalMatches: results.reduce((sum, r) => sum + r.matches.length, 0),
|
|
5434
|
+
// A pattern written for a CR can't find a CRLF end's, nor one written
|
|
5435
|
+
// for U+FEFF a BOM, which lines as read shows them leave out, so the
|
|
5436
|
+
// answer says why it didn't, and names the files that have them.
|
|
5437
|
+
...leftOutNote(input.pattern, crlfFiles, bomFiles, this.anyRunsDirect()),
|
|
5438
|
+
...(skipped.length > 0 ? { skipped } : {}),
|
|
5439
|
+
...(incomplete.length > 0 ? { incomplete } : {}),
|
|
4747
5440
|
};
|
|
5441
|
+
return textAnswer(grepText(answer, contextBefore), { ...answer });
|
|
5442
|
+
}
|
|
5443
|
+
|
|
5444
|
+
/**
|
|
5445
|
+
* Why a grep or a glob whose `path` (`given`, in the mount at
|
|
5446
|
+
* `relativePath`) and filter or pattern can't overlap did nothing. grep's
|
|
5447
|
+
* filter is matched from the mount's root, and in the mount its first
|
|
5448
|
+
* segment names, either tool's is read mount-prefixed (`prefixed`), from
|
|
5449
|
+
* that mount's root; either way no file at or beneath the path can pass
|
|
5450
|
+
* it. Where the path isn't a file, it names the one that reaches beneath
|
|
5451
|
+
* it, the one meant if it was written relative to the path, mount-prefixed
|
|
5452
|
+
* as the tools write paths: the path's own `{`, `}`, `*` and `?` stand as
|
|
5453
|
+
* `?`, which matches each of them, and since the search stays beneath the
|
|
5454
|
+
* path, a `?` that would match a sibling's name too takes nothing in from
|
|
5455
|
+
* outside it. One with an empty, `.` or `..` segment gets no suggestion,
|
|
5456
|
+
* since no path holds such a segment. Segments are read as written, so one
|
|
5457
|
+
* a brace group splits around isn't told apart.
|
|
5458
|
+
*/
|
|
5459
|
+
private async disjointRefusal(tool: 'grep' | 'glob', given: string, filter: string, prefixed: boolean, mount: MountState, relativePath: string, root: string): Promise<string> {
|
|
5460
|
+
const name = mount.config.name;
|
|
5461
|
+
const rest = prefixed ? filter.slice(filter.indexOf('/') + 1) : filter;
|
|
5462
|
+
const read = `The ${tool} did nothing: its ${tool === 'grep' ? 'glob' : 'pattern'} ${JSON.stringify(filter)} `
|
|
5463
|
+
+ (prefixed ? `is read mount-prefixed, from the root of mount ${JSON.stringify(name)}` : 'is matched against paths from the mount\'s root');
|
|
5464
|
+
if (await this.namesFile(mount, relativePath, root)) return `${read}, so ${JSON.stringify(given)} doesn't match it.`;
|
|
5465
|
+
const within = `${read}, so no file within ${JSON.stringify(given)} matches it.`;
|
|
5466
|
+
if (rest.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')) return within;
|
|
5467
|
+
const reaching = `${name}/${relativePath.replace(/[{}*?]/g, '?')}/${rest}`;
|
|
5468
|
+
return `${within} To ${tool === 'grep' ? 'filter' : 'match'} beneath it, write ${JSON.stringify(reaching)}.`;
|
|
4748
5469
|
}
|
|
4749
5470
|
|
|
4750
5471
|
/**
|
|
@@ -4838,7 +5559,7 @@ export class WorkspaceModule implements Module {
|
|
|
4838
5559
|
const entry = store.treeGet(mount.treeStateId, report.path);
|
|
4839
5560
|
const blob = entry ? store.getBlob(entry.blobHash) : null;
|
|
4840
5561
|
if (blob) {
|
|
4841
|
-
each({ file, state: report.state, version: 'workspace',
|
|
5562
|
+
each({ file, state: report.state, version: 'workspace', bytes: blob });
|
|
4842
5563
|
} else if (entry) {
|
|
4843
5564
|
skipped.push({ file, reason: 'the workspace copy is missing from the store, so it was not searched' });
|
|
4844
5565
|
}
|
|
@@ -4852,7 +5573,7 @@ export class WorkspaceModule implements Module {
|
|
|
4852
5573
|
const changed = report.conflict?.diskChangedSinceRecorded === true;
|
|
4853
5574
|
const diskBlob = disk?.stored ? store.getBlob(disk.stored) : null;
|
|
4854
5575
|
if (diskBlob) {
|
|
4855
|
-
each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk',
|
|
5576
|
+
each({ file, state: report.state, version: changed ? 'recorded-disk' : 'conflicting-disk', bytes: diskBlob });
|
|
4856
5577
|
}
|
|
4857
5578
|
if (disk?.stored && !diskBlob) {
|
|
4858
5579
|
skipped.push({ file, reason: 'conflict: the disk version recorded with the conflict is missing from the store, so it was not searched' });
|
|
@@ -4905,7 +5626,7 @@ export class WorkspaceModule implements Module {
|
|
|
4905
5626
|
const what = { size: read.size, ...(mimeType ? { mimeType } : {}) };
|
|
4906
5627
|
if (read.bytes === null) skipped.push({ file, reason: `over the size limit (${maxSize} bytes)`, ...what });
|
|
4907
5628
|
else if (looksBinary(read.head)) skipped.push({ file, reason: 'binary: a NUL in its first 8 KB', ...what });
|
|
4908
|
-
else each({ file, version: 'disk',
|
|
5629
|
+
else each({ file, version: 'disk', bytes: read.bytes });
|
|
4909
5630
|
});
|
|
4910
5631
|
return { skipped, incomplete: incomplete.map((region) => ({ path: `${name}/${region.path}`, reason: region.reason })) };
|
|
4911
5632
|
}
|
|
@@ -4922,7 +5643,7 @@ export class WorkspaceModule implements Module {
|
|
|
4922
5643
|
if (mount.direct) {
|
|
4923
5644
|
// A direct mount keeps nothing to count: its kind, and the ignore
|
|
4924
5645
|
// list its walks go by, whose default no config shows.
|
|
4925
|
-
status[name] = { path: mount.config.path, mode: mount.config.mode, backend: 'direct', ignore: this.ignoreOf(mount) };
|
|
5646
|
+
status[name] = { path: mount.config.path, mode: mount.config.mode ?? 'read-write', backend: 'direct', ignore: this.ignoreOf(mount) };
|
|
4926
5647
|
continue;
|
|
4927
5648
|
}
|
|
4928
5649
|
// What the workspace's history holds outside the mount's include isn't the mount's,
|
|
@@ -4937,7 +5658,7 @@ export class WorkspaceModule implements Module {
|
|
|
4937
5658
|
const currentBranch = store.currentBranch();
|
|
4938
5659
|
status[name] = {
|
|
4939
5660
|
path: mount.config.path,
|
|
4940
|
-
mode: mount.config.mode,
|
|
5661
|
+
mode: mount.config.backend === 'direct' ? mount.config.mode ?? 'read-write' : mount.config.mode,
|
|
4941
5662
|
// A mount configured direct runs as 'chronicle' this session
|
|
4942
5663
|
// (chooseKinds), by the direct kind's ignore list where its config
|
|
4943
5664
|
// names none.
|
|
@@ -5109,8 +5830,9 @@ export class WorkspaceModule implements Module {
|
|
|
5109
5830
|
}
|
|
5110
5831
|
mountsToMaterialize = [{ name: input.mount, mount: m }];
|
|
5111
5832
|
} else {
|
|
5833
|
+
// Not a mount running direct, whose writes are on disk already.
|
|
5112
5834
|
mountsToMaterialize = [...this.mounts.entries()]
|
|
5113
|
-
.filter(([, m]) => m.config.mode === 'read-write')
|
|
5835
|
+
.filter(([, m]) => m.config.mode === 'read-write' && !m.direct)
|
|
5114
5836
|
.map(([name, mount]) => ({ name, mount }));
|
|
5115
5837
|
}
|
|
5116
5838
|
|
|
@@ -5131,6 +5853,12 @@ export class WorkspaceModule implements Module {
|
|
|
5131
5853
|
let proceeded = 0;
|
|
5132
5854
|
|
|
5133
5855
|
for (const { name, mount } of mountsToMaterialize) {
|
|
5856
|
+
// Only a writable direct mount the caller named gets here: it keeps no
|
|
5857
|
+
// tree to push. A read-only one is named as any read-only mount is.
|
|
5858
|
+
if (mount.direct && mount.config.mode !== 'read-only') {
|
|
5859
|
+
blocked.push({ mount: name, reason: DIRECT_MATERIALIZE_SKIPPED });
|
|
5860
|
+
continue;
|
|
5861
|
+
}
|
|
5134
5862
|
if (mount.config.mode === 'read-only') {
|
|
5135
5863
|
// Only a mount the caller named gets here read-only (a bare
|
|
5136
5864
|
// materialize takes the read-write ones): say why nothing was written.
|
|
@@ -5243,7 +5971,8 @@ export class WorkspaceModule implements Module {
|
|
|
5243
5971
|
if (!this.running()) return [];
|
|
5244
5972
|
const store = this.getStore();
|
|
5245
5973
|
const mount = this.mounts.get(mountName);
|
|
5246
|
-
|
|
5974
|
+
// A direct mount keeps no tree to push: disk is its only copy.
|
|
5975
|
+
if (!mount || mount.config.mode === 'read-only' || mount.direct) return [];
|
|
5247
5976
|
|
|
5248
5977
|
// Reset tracking — we're deliberately materializing on the new branch
|
|
5249
5978
|
mount.lastMaterializedBranchId = null;
|
|
@@ -5470,9 +6199,9 @@ export class WorkspaceModule implements Module {
|
|
|
5470
6199
|
* on disk (a shell command's files, a deletion) is in the workspace when the
|
|
5471
6200
|
* agent next looks. On a mount over its walkCap, that holds for the first
|
|
5472
6201
|
* walkCap entries in the walk's order, which every batch's scan takes; files
|
|
5473
|
-
* past them come in through syncs
|
|
5474
|
-
*
|
|
5475
|
-
* outlasts the deadline stops holding the round: the miss is recorded on
|
|
6202
|
+
* past them come in through syncs, the listings that reach them, and a read
|
|
6203
|
+
* of the path, which takes disk's change in first as a listing does. A scan
|
|
6204
|
+
* that outlasts the deadline stops holding the round: the miss is recorded on
|
|
5476
6205
|
* the mount's status and pushed as a `workspace:agent-action-scan-incomplete`
|
|
5477
6206
|
* event, and the scan continues, deciding on the branch selected when it
|
|
5478
6207
|
* applies.
|
|
@@ -5631,13 +6360,14 @@ export class WorkspaceModule implements Module {
|
|
|
5631
6360
|
|
|
5632
6361
|
/**
|
|
5633
6362
|
* A text grep searches (textsFromStore, textsFromDisk): its mount-prefixed
|
|
5634
|
-
* file, which copy it is,
|
|
6363
|
+
* file, which copy it is, on a 'chronicle' mount the path's state, and its
|
|
6364
|
+
* bytes, whose lines grep matches as read shows them (fileText).
|
|
5635
6365
|
*/
|
|
5636
6366
|
interface GrepText {
|
|
5637
6367
|
file: string;
|
|
5638
6368
|
state?: string;
|
|
5639
6369
|
version: 'workspace' | 'conflicting-disk' | 'recorded-disk' | 'disk';
|
|
5640
|
-
|
|
6370
|
+
bytes: Buffer;
|
|
5641
6371
|
}
|
|
5642
6372
|
|
|
5643
6373
|
/** A file, or a copy of one, grep doesn't search, with why. */
|
|
@@ -5736,16 +6466,33 @@ function watchOf(config: MountConfig): 'always' | 'on-agent-action' | 'never' {
|
|
|
5736
6466
|
return config.watch ?? (config.backend === 'direct' ? 'never' : 'always');
|
|
5737
6467
|
}
|
|
5738
6468
|
|
|
6469
|
+
/**
|
|
6470
|
+
* Why a write, edit, deletion or peer module's writeBinary to this mount is
|
|
6471
|
+
* refused, or null where it may go ahead: a read-only mount, and a mount
|
|
6472
|
+
* configured direct that runs as 'chronicle' this session (chooseKinds),
|
|
6473
|
+
* since the store holds changes there that disk lacks. A write there would
|
|
6474
|
+
* stay in the store, beside an agent told the mount is its checkout, and
|
|
6475
|
+
* add to what keeps it off direct; refused, what's held only shrinks, and
|
|
6476
|
+
* the next start once none is held runs it direct.
|
|
6477
|
+
*/
|
|
6478
|
+
function writeRefusal(mount: MountState): string | null {
|
|
6479
|
+
const name = mount.config.name;
|
|
6480
|
+
if (mount.config.mode === 'read-only') return `Mount "${name}" is read-only`;
|
|
6481
|
+
if (mount.config.backend === 'direct' && !mount.direct) {
|
|
6482
|
+
return `Mount "${name}" is configured direct, and runs as 'chronicle' this session because its start found the store holding changes there that disk lacks, `
|
|
6483
|
+
+ `so it takes no writes this session, and calling again is refused the same way. To run it direct again, settle what's held `
|
|
6484
|
+
+ `(sync each held path to take disk's copy, or materialize mount "${name}" by name to write the store's copies to disk), and the host's next start runs it direct`;
|
|
6485
|
+
}
|
|
6486
|
+
return null;
|
|
6487
|
+
}
|
|
6488
|
+
|
|
5739
6489
|
/**
|
|
5740
6490
|
* What a direct mount's config sets that would never act there, each with
|
|
5741
|
-
* the way out (MountConfig.backend): a direct mount
|
|
5742
|
-
*
|
|
6491
|
+
* the way out (MountConfig.backend): a direct mount has no watcher to tune,
|
|
6492
|
+
* no change event to wake on and no push to make. Either mode is its own.
|
|
5743
6493
|
*/
|
|
5744
6494
|
function directRefusals(mount: MountConfig): string[] {
|
|
5745
6495
|
const refused: string[] = [];
|
|
5746
|
-
if (mount.mode !== 'read-only') {
|
|
5747
|
-
refused.push(mount.mode === undefined ? "mode must be 'read-only' (left out, it's 'read-write')" : `mode must be 'read-only', not ${shownValue(mount.mode)}`);
|
|
5748
|
-
}
|
|
5749
6496
|
if (mount.watch !== undefined && mount.watch !== 'never') refused.push(`watch ${shownValue(mount.watch)} would watch nothing (leave it out, or set 'never')`);
|
|
5750
6497
|
for (const key of ['watchDebounceMs', 'watchRootPollMs'] as const) {
|
|
5751
6498
|
if (mount[key] !== undefined) refused.push(`${key} would tune no watcher (leave it out)`);
|
|
@@ -5756,6 +6503,124 @@ function directRefusals(mount: MountConfig): string[] {
|
|
|
5756
6503
|
return refused;
|
|
5757
6504
|
}
|
|
5758
6505
|
|
|
6506
|
+
/**
|
|
6507
|
+
* An agent's latest direct write on a mount, P: the store's branch, and its
|
|
6508
|
+
* head when the write landed (`after`); and the id of the message that last
|
|
6509
|
+
* told the agent its history no longer holds P, N, or null.
|
|
6510
|
+
*/
|
|
6511
|
+
interface DirectHead {
|
|
6512
|
+
branchId: string;
|
|
6513
|
+
after: number;
|
|
6514
|
+
notice: string | null;
|
|
6515
|
+
}
|
|
6516
|
+
|
|
6517
|
+
/** A store position: a branch and its head. */
|
|
6518
|
+
type DirectPosition = Pick<DirectHead, 'branchId' | 'after'>;
|
|
6519
|
+
|
|
6520
|
+
/** The global record type directHeads is saved as: a checkpoint at a clean stop that changed it. */
|
|
6521
|
+
const DIRECT_HEADS_RECORD_TYPE = 'workspace/direct-heads';
|
|
6522
|
+
|
|
6523
|
+
/** One mount's positions at one root (resolved, as DiskAgreement keeps a root), by agent, with N only where it's set. */
|
|
6524
|
+
interface DirectHeadsEntry {
|
|
6525
|
+
mount: string;
|
|
6526
|
+
root: string;
|
|
6527
|
+
agents: Record<string, { branchId: string; after: number; notice?: string }>;
|
|
6528
|
+
}
|
|
6529
|
+
|
|
6530
|
+
/** directHeads as saved: an entry per mount and root its agents wrote direct under. */
|
|
6531
|
+
type DirectHeadsSnapshot = DirectHeadsEntry[];
|
|
6532
|
+
|
|
6533
|
+
/**
|
|
6534
|
+
* A saved record as saveDirectHeads writes it, or a throw naming what isn't:
|
|
6535
|
+
* a list of entries, each a mount's name, root and agents, and each agent's
|
|
6536
|
+
* branch, head and, where set, notice. Checked whole, carried entries
|
|
6537
|
+
* included, so a record of another shape is refused at load rather than
|
|
6538
|
+
* carried into the next save.
|
|
6539
|
+
*/
|
|
6540
|
+
function checkedHeads(value: unknown): DirectHeadsSnapshot {
|
|
6541
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6542
|
+
if (!Array.isArray(value)) throw new Error("the record isn't of the shape saved");
|
|
6543
|
+
for (const saved of value as unknown[]) {
|
|
6544
|
+
if (!isObject(saved) || typeof saved.mount !== 'string' || typeof saved.root !== 'string' || !isObject(saved.agents)) {
|
|
6545
|
+
throw new Error(`an entry isn't of the shape saved: ${JSON.stringify(saved)?.slice(0, 200)}`);
|
|
6546
|
+
}
|
|
6547
|
+
for (const [agent, head] of Object.entries(saved.agents)) {
|
|
6548
|
+
if (!isObject(head) || typeof head.branchId !== 'string' || typeof head.after !== 'number'
|
|
6549
|
+
|| (head.notice !== undefined && typeof head.notice !== 'string')) {
|
|
6550
|
+
throw new Error(`agent ${JSON.stringify(agent)}'s position on mount ${JSON.stringify(saved.mount)} isn't of the shape saved`);
|
|
6551
|
+
}
|
|
6552
|
+
}
|
|
6553
|
+
}
|
|
6554
|
+
return value as DirectHeadsSnapshot;
|
|
6555
|
+
}
|
|
6556
|
+
|
|
6557
|
+
/** A saved record's content, whatever order its mounts and agents were written in, to tell whether a save would change it. */
|
|
6558
|
+
function canonicalHeads(snapshot: DirectHeadsSnapshot): string {
|
|
6559
|
+
return JSON.stringify(snapshot.map(({ mount, root, agents }) => [mount, root, Object.keys(agents).sort().map((agent) => {
|
|
6560
|
+
const { branchId, after, notice } = agents[agent]!;
|
|
6561
|
+
return [agent, branchId, after, notice ?? null];
|
|
6562
|
+
})]).sort((a, b) => {
|
|
6563
|
+
const [x, y] = [JSON.stringify(a.slice(0, 2)), JSON.stringify(b.slice(0, 2))];
|
|
6564
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
6565
|
+
}));
|
|
6566
|
+
}
|
|
6567
|
+
|
|
6568
|
+
/**
|
|
6569
|
+
* How many times a direct edit reads and plans a file another writer keeps
|
|
6570
|
+
* changing: enough to land on top of a writer that changes it now and then,
|
|
6571
|
+
* such as a formatter or a build, and bounded for one that never stops.
|
|
6572
|
+
*/
|
|
6573
|
+
const DIRECT_EDIT_ATTEMPTS = 3;
|
|
6574
|
+
|
|
6575
|
+
/** Why a direct write, edit or deletion did nothing, for its answer: a refusal's words, or any other failure's message. */
|
|
6576
|
+
function directReason(error: unknown): string {
|
|
6577
|
+
if (error instanceof FileChanged) return 'it changed on disk while this call was being applied';
|
|
6578
|
+
// A deletion's root check (unlinkContained) has a store-backed mount's
|
|
6579
|
+
// words, whose remedy, a sync with acceptRoot, a direct mount has no use for.
|
|
6580
|
+
if (error instanceof Error && error.message === ROOT_REPLACED) return ROOT_MOVED;
|
|
6581
|
+
return error instanceof Error ? error.message : String(error);
|
|
6582
|
+
}
|
|
6583
|
+
|
|
6584
|
+
/** "a", "a and b", or "a, b and c". */
|
|
6585
|
+
function listed(items: readonly string[]): string {
|
|
6586
|
+
return items.length < 2 ? items.join('') : `${items.slice(0, -1).join(', ')} and ${items.at(-1)}`;
|
|
6587
|
+
}
|
|
6588
|
+
|
|
6589
|
+
/**
|
|
6590
|
+
* The note for a direct write that gave a hard-linked path a file of its
|
|
6591
|
+
* own: the other links keep the old text. Null for a file with none.
|
|
6592
|
+
*/
|
|
6593
|
+
function linksNote(path: string, staged: Staged, tool: 'write' | EditTool): string | null {
|
|
6594
|
+
const n = staged.otherLinks;
|
|
6595
|
+
if (n <= 0) return null;
|
|
6596
|
+
return `${path} had ${n === 1 ? 'another hard link' : `${n} other hard links`}, which ${n === 1 ? 'keeps' : 'keep'} its old text: `
|
|
6597
|
+
+ `the ${tool === 'write' ? 'write' : tool === 'edit' ? 'edit' : 'call'} gave this path a file of its own.`;
|
|
6598
|
+
}
|
|
6599
|
+
|
|
6600
|
+
/**
|
|
6601
|
+
* A refusal of openContainedFile's as a direct tool gives it: its message,
|
|
6602
|
+
* and for one that names only the path, its errno too, as a direct read's
|
|
6603
|
+
* answer gives it (bytesOnDisk).
|
|
6604
|
+
*/
|
|
6605
|
+
function containedRefusal(err: unknown, path: string): string {
|
|
6606
|
+
const bare = err instanceof WorkspaceReadError && err.errno !== undefined && err.message === `Unable to read file: ${path}`;
|
|
6607
|
+
return bare ? `${err.message} (${err.errno})` : err instanceof Error ? err.message : String(err);
|
|
6608
|
+
}
|
|
6609
|
+
|
|
6610
|
+
/**
|
|
6611
|
+
* The notice that writes an agent made direct remain on disk from
|
|
6612
|
+
* turns the agent's history no longer holds. `git status` stands beside
|
|
6613
|
+
* `git diff` because a departed turn's new file is untracked, which `git
|
|
6614
|
+
* diff` doesn't show; and the warning says "unstaged", since `git checkout
|
|
6615
|
+
* -- <file>` puts back a file's staged text, so a staged change survives it.
|
|
6616
|
+
*/
|
|
6617
|
+
function headMoveText(mounts: readonly string[]): string {
|
|
6618
|
+
const one = mounts.length === 1;
|
|
6619
|
+
return `[workspace] Writes to ${listed(mounts.map((name) => `\`${name}\``))} from turns the history no longer holds are still on disk. `
|
|
6620
|
+
+ `If ${one ? "it's a git checkout" : "they're git checkouts"}, \`git status\` and \`git diff\` show them; `
|
|
6621
|
+
+ 'a `git checkout` of a file drops every unstaged change in it, including the ones the history still holds.';
|
|
6622
|
+
}
|
|
6623
|
+
|
|
5759
6624
|
/** A change commitToolChanges makes to one path's tree entry. */
|
|
5760
6625
|
type ToolChange = { kind: 'set'; blobHash: string; size: number; mode?: number } | { kind: 'remove'; previousHash: string };
|
|
5761
6626
|
|
|
@@ -5770,6 +6635,64 @@ interface EditTarget {
|
|
|
5770
6635
|
specs: EditSpec[];
|
|
5771
6636
|
}
|
|
5772
6637
|
|
|
6638
|
+
/**
|
|
6639
|
+
* One file's edits planned against its bytes, as both kinds of mount plan
|
|
6640
|
+
* them: its text decoded, every edit matched in it (edit-plan.ts), and the
|
|
6641
|
+
* result held to the mount's size limit. Each problem found goes into
|
|
6642
|
+
* `problems`, and then, as for a file named only by edits whose own input
|
|
6643
|
+
* was refused, there's no plan (null).
|
|
6644
|
+
*/
|
|
6645
|
+
function planTargetEdits(
|
|
6646
|
+
tool: EditTool,
|
|
6647
|
+
target: EditTarget,
|
|
6648
|
+
bytes: Buffer,
|
|
6649
|
+
hints: ReturnType<typeof hintBudget>,
|
|
6650
|
+
maxSize: number,
|
|
6651
|
+
counts: ReturnType<typeof countBudget>,
|
|
6652
|
+
problems: EditProblem[],
|
|
6653
|
+
): FilePlan | null {
|
|
6654
|
+
// Decoding would turn each invalid sequence into U+FFFD, and writing
|
|
6655
|
+
// the text back would change bytes the edits never named.
|
|
6656
|
+
const file = decodeForEdit(bytes);
|
|
6657
|
+
if (!file) {
|
|
6658
|
+
problems.push({
|
|
6659
|
+
kind: 'file',
|
|
6660
|
+
edits: target.edits,
|
|
6661
|
+
path: target.path,
|
|
6662
|
+
text: `${target.path} isn't valid UTF-8, so editing it would change bytes outside the text it replaces. `
|
|
6663
|
+
+ 'Read shows each invalid byte sequence as U+FFFD, and a write replaces the whole file as UTF-8, so writing back what read shows puts U+FFFD in their place.',
|
|
6664
|
+
whole: false,
|
|
6665
|
+
});
|
|
6666
|
+
return null;
|
|
6667
|
+
}
|
|
6668
|
+
if (!target.specs.length) return null;
|
|
6669
|
+
const planned = planFile(file, bytes, target.specs, hints, maxSize, counts);
|
|
6670
|
+
if ('problems' in planned) {
|
|
6671
|
+
// One at a time: a call of many edits can have more problems
|
|
6672
|
+
// than a spread passes as arguments.
|
|
6673
|
+
for (const problem of planned.problems) problems.push({ ...problem, path: target.path });
|
|
6674
|
+
return null;
|
|
6675
|
+
}
|
|
6676
|
+
// The planner refuses a file its counts take past the limit before
|
|
6677
|
+
// building it, from the least its bytes can be; one it built past
|
|
6678
|
+
// the limit, as its CRs or multi-byte characters can take it, is
|
|
6679
|
+
// refused here.
|
|
6680
|
+
if ('tooLarge' in planned || planned.plan.bytes.length > maxSize) {
|
|
6681
|
+
const size = 'tooLarge' in planned ? `at least ${planned.tooLarge.toLocaleString('en-US')}` : planned.plan.bytes.length.toLocaleString('en-US');
|
|
6682
|
+
problems.push({
|
|
6683
|
+
kind: 'file',
|
|
6684
|
+
edits: target.edits,
|
|
6685
|
+
path: target.path,
|
|
6686
|
+
text: `${target.path} would be ${size} bytes with ${tool === 'edit' ? 'this edit' : editList(target.edits)}, `
|
|
6687
|
+
+ `past the ${maxSize.toLocaleString('en-US')}-byte limit this mount sets on a file, so the workspace couldn't keep it. `
|
|
6688
|
+
+ "An edit that leaves the file within the limit applies. The limit is the mount's maxFileSize setting, which only whoever runs the host can change.",
|
|
6689
|
+
whole: false,
|
|
6690
|
+
});
|
|
6691
|
+
return null;
|
|
6692
|
+
}
|
|
6693
|
+
return planned.plan;
|
|
6694
|
+
}
|
|
6695
|
+
|
|
5773
6696
|
/**
|
|
5774
6697
|
* Whether a tree entry is what a change leaves: for a set, an entry with its
|
|
5775
6698
|
* blob, size and mode; for a removal, none.
|
|
@@ -5861,14 +6784,43 @@ function storedButUnfinished(
|
|
|
5861
6784
|
}
|
|
5862
6785
|
|
|
5863
6786
|
/**
|
|
5864
|
-
*
|
|
5865
|
-
* last
|
|
5866
|
-
*
|
|
6787
|
+
* What a read says about the copy it shows, when that isn't simply the one the
|
|
6788
|
+
* workspace last agreed on with disk: disk's newer text, taken in first; the
|
|
6789
|
+
* workspace's copy, because disk's couldn't be checked or disk has none that
|
|
6790
|
+
* anything accounts for; or a draft in conflict with disk's change. A file
|
|
6791
|
+
* the workspace didn't hold has no older copy, so taking it in needs no word.
|
|
6792
|
+
* A conflict's note names the ways to settle it that work on the path's mount.
|
|
5867
6793
|
*/
|
|
5868
|
-
function
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
6794
|
+
function whichCopyRead(report: PathReport | undefined, tookChange: boolean, readOnly: boolean): string[] {
|
|
6795
|
+
if (tookChange) {
|
|
6796
|
+
return ['Disk\'s copy had changed since the workspace last agreed with it, so it was taken in first, and this read shows that text.'];
|
|
6797
|
+
}
|
|
6798
|
+
if (report?.state === 'unverified') {
|
|
6799
|
+
return [`The path couldn't be checked against disk (${report.note ?? 'not observed'}), so this read shows the workspace's copy.`];
|
|
6800
|
+
}
|
|
6801
|
+
if (report?.state === 'disk-missing-provenance-unknown') {
|
|
6802
|
+
return ['Disk has no copy of this file, and nothing records whether it was deleted there, so this read shows the workspace\'s copy.'];
|
|
6803
|
+
}
|
|
6804
|
+
if (report?.state === 'conflict') {
|
|
6805
|
+
const kind = report.conflict?.kind ?? 'both-changed';
|
|
6806
|
+
// A sync takes disk's state as it is now, which a recorded conflict's kind
|
|
6807
|
+
// doesn't follow: where disk has no copy, the sync takes its deletion, and
|
|
6808
|
+
// the workspace's copy goes with it. Where disk has one, the sync keeps it
|
|
6809
|
+
// and gives up the workspace's, taking disk's in only where the workspace
|
|
6810
|
+
// can hold it (not binary or too large). A read-only mount can hold a
|
|
6811
|
+
// copy disk doesn't, such as one a branch switch selected, but a
|
|
6812
|
+
// materialize, forced or not, writes nothing to it.
|
|
6813
|
+
const sync = report.diskNow === 'absent'
|
|
6814
|
+
? 'Sync the path to take disk\'s deletion, which removes the workspace\'s copy'
|
|
6815
|
+
: 'Sync the path to keep disk\'s copy and give up the workspace\'s';
|
|
6816
|
+
const force = readOnly
|
|
6817
|
+
? '. The mount is read-only, so nothing writes the workspace\'s copy to disk.'
|
|
6818
|
+
: report.diskNow === 'absent'
|
|
6819
|
+
? ', or materialize it with force to write the workspace\'s copy back to disk.'
|
|
6820
|
+
: ', or materialize it with force to write the workspace\'s over disk\'s.';
|
|
6821
|
+
return [`The path is in conflict with disk (${kind}): this read shows the workspace's copy. ${sync}${force}`];
|
|
6822
|
+
}
|
|
6823
|
+
return [];
|
|
5872
6824
|
}
|
|
5873
6825
|
|
|
5874
6826
|
/**
|
|
@@ -5939,6 +6891,87 @@ function inputProblem(
|
|
|
5939
6891
|
return null;
|
|
5940
6892
|
}
|
|
5941
6893
|
|
|
6894
|
+
/** How many files a grep note names before it gives the count of the rest. */
|
|
6895
|
+
const NOTE_FILES_MAX = 20;
|
|
6896
|
+
|
|
6897
|
+
/**
|
|
6898
|
+
* The files a grep note names, in the order grep searched them, as its
|
|
6899
|
+
* results are: at most NOTE_FILES_MAX of them, then how many more and how to
|
|
6900
|
+
* see them. Each is named by its mount-prefixed path, with what `detail` says
|
|
6901
|
+
* of it.
|
|
6902
|
+
*/
|
|
6903
|
+
function namedFiles<T>(files: Map<string, T>, detail: (value: T) => string): string {
|
|
6904
|
+
const names = [...files.keys()];
|
|
6905
|
+
const shown = names.slice(0, NOTE_FILES_MAX).map((name) => `${name}${detail(files.get(name)!)}`);
|
|
6906
|
+
const rest = names.length - shown.length;
|
|
6907
|
+
return `${shown.join(', ')}${rest > 0 ? `, and ${rest} more; narrow with path or glob` : ''}`;
|
|
6908
|
+
}
|
|
6909
|
+
|
|
6910
|
+
/** What a note says of a conflict's disk version, which read doesn't show. */
|
|
6911
|
+
const ofVersion = (version: GrepText['version']): string =>
|
|
6912
|
+
version === 'conflicting-disk' || version === 'recorded-disk' ? ` in its ${version} version` : '';
|
|
6913
|
+
|
|
6914
|
+
/**
|
|
6915
|
+
* grep's note for a pattern that names a character its lines leave out, where
|
|
6916
|
+
* a file it searched has one: it says how many files searched have it and
|
|
6917
|
+
* names them, so a search for a CR or a BOM still finds the files that have
|
|
6918
|
+
* them. The CR note gives each file's ends, CRLF or mixed, and either note
|
|
6919
|
+
* names a conflict's disk version as that version. The note says a
|
|
6920
|
+
* character read shows it in the workspace's copy, since grep also searches a
|
|
6921
|
+
* conflict's disk version, which read doesn't show. A direct mount keeps no
|
|
6922
|
+
* workspace copy, and its read shows disk's file, so on a host with one
|
|
6923
|
+
* (`direct`) the note names that too, as the tools' descriptions mention
|
|
6924
|
+
* direct mounts only on such a host.
|
|
6925
|
+
*/
|
|
6926
|
+
function leftOutNote(
|
|
6927
|
+
pattern: string,
|
|
6928
|
+
crlfFiles: Map<string, { ends: 'CRLF' | 'mixed'; version: GrepText['version'] }>,
|
|
6929
|
+
bomFiles: Map<string, GrepText['version']>,
|
|
6930
|
+
direct: boolean,
|
|
6931
|
+
): { note?: string } {
|
|
6932
|
+
if (crlfFiles.size === 0 && bomFiles.size === 0) return {};
|
|
6933
|
+
const named = namedLeftOut(pattern);
|
|
6934
|
+
const onDisk = direct ? ', or in disk\'s file on a direct mount' : '';
|
|
6935
|
+
const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? `file searched ${one}` : `files searched ${many}`}`;
|
|
6936
|
+
const notes = [
|
|
6937
|
+
named.cr && crlfFiles.size > 0
|
|
6938
|
+
? `The pattern names a CR, and ${count(crlfFiles.size, 'has', 'have')} CRLF line ends: `
|
|
6939
|
+
+ `${namedFiles(crlfFiles, ({ ends, version }) => ` (${ends}${ofVersion(version)})`)}. Lines are matched as read shows them, `
|
|
6940
|
+
+ `which leaves out a CRLF end's CR unless the line's own text ends in a CR. read with offsetChars shows every CR in the workspace's copy${onDisk}.`
|
|
6941
|
+
: null,
|
|
6942
|
+
named.bom && bomFiles.size > 0
|
|
6943
|
+
? `The pattern names U+FEFF, and ${count(bomFiles.size, 'starts', 'start')} with a BOM: `
|
|
6944
|
+
+ `${namedFiles(bomFiles, (version) => (ofVersion(version) ? ` (${ofVersion(version).slice(1)})` : ''))}. Lines are matched as read shows them, `
|
|
6945
|
+
+ `and a BOM isn't part of the first line. read with offsetChars shows any BOM ${direct ? `in the workspace's copy${onDisk}` : 'the workspace\'s copy has'}.`
|
|
6946
|
+
: null,
|
|
6947
|
+
].filter((n) => n !== null);
|
|
6948
|
+
return notes.length ? { note: notes.join(' ') } : {};
|
|
6949
|
+
}
|
|
6950
|
+
|
|
6951
|
+
/**
|
|
6952
|
+
* Which of the characters grep's lines leave out a pattern names: a CR, as a
|
|
6953
|
+
* literal one or as `\r`, `\x0d`, `\u000d`, `\cM`, `\015` or a backslash
|
|
6954
|
+
* before a literal CR; and U+FEFF, as a literal one, `\uFEFF` or a backslash
|
|
6955
|
+
* before a literal one. An escape counts only where its backslash isn't itself
|
|
6956
|
+
* escaped. A pattern compiled without flags can match a CR other ways too,
|
|
6957
|
+
* such as `\15` in a pattern of fewer than 15 groups or a class range that
|
|
6958
|
+
* spans U+000D, and those go unnoticed.
|
|
6959
|
+
*/
|
|
6960
|
+
function namedLeftOut(pattern: string): { cr: boolean; bom: boolean } {
|
|
6961
|
+
const named = { cr: false, bom: false };
|
|
6962
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
6963
|
+
if (pattern[i] === '\r') named.cr = true;
|
|
6964
|
+
else if (pattern[i] === '\uFEFF') named.bom = true;
|
|
6965
|
+
else if (pattern[i] === '\\') {
|
|
6966
|
+
const next = pattern.slice(i + 1, i + 6);
|
|
6967
|
+
if (/^(?:r|\r|x0[dD]|u000[dD]|c[mM]|015)/.test(next)) named.cr = true;
|
|
6968
|
+
else if (/^(?:u[fF][eE][fF][fF]|\uFEFF)/.test(next)) named.bom = true;
|
|
6969
|
+
i++;
|
|
6970
|
+
}
|
|
6971
|
+
}
|
|
6972
|
+
return named;
|
|
6973
|
+
}
|
|
6974
|
+
|
|
5942
6975
|
/**
|
|
5943
6976
|
* A glob's reading in the mount its first segment names, in place of the
|
|
5944
6977
|
* pattern as written there. Every path the tools take and return is
|