@modelprofile.com/flexharness 10.17.0 → 10.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/errors.d.ts +4 -0
- package/dist_ts/errors.js +9 -1
- package/dist_ts/functions.toolpermissions.d.ts +24 -0
- package/dist_ts/functions.toolpermissions.js +38 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +2 -1
- package/dist_ts/plugins.d.ts +1 -1
- package/dist_ts/plugins.js +1 -1
- package/dist_ts_chat_harness/classes.chatclient.d.ts +95 -0
- package/dist_ts_chat_harness/classes.chatclient.js +263 -0
- package/dist_ts_chat_harness/classes.chatrequesterror.d.ts +4 -0
- package/dist_ts_chat_harness/classes.chatrequesterror.js +8 -0
- package/dist_ts_chat_harness/classes.transcript.d.ts +1 -1
- package/dist_ts_chat_harness/classes.transcript.js +1 -1
- package/dist_ts_chat_harness/functions.intents.d.ts +1 -2
- package/dist_ts_chat_harness/functions.intents.js +1 -1
- package/dist_ts_chat_harness/functions.mapping.d.ts +1 -1
- package/dist_ts_chat_harness/functions.mapping.js +1 -1
- package/dist_ts_chat_harness/index.d.ts +3 -0
- package/dist_ts_chat_harness/index.js +3 -1
- package/dist_ts_chat_harness/interfaces.chat.d.ts +136 -0
- package/dist_ts_chat_harness/interfaces.chat.js +2 -0
- package/dist_ts_chat_harness/plugins.js +1 -1
- package/dist_ts_chat_harness_server/classes.chathub.d.ts +78 -0
- package/dist_ts_chat_harness_server/classes.chathub.js +332 -0
- package/dist_ts_chat_harness_server/classes.chatsubscription.d.ts +55 -0
- package/dist_ts_chat_harness_server/classes.chatsubscription.js +236 -0
- package/dist_ts_chat_harness_server/classes.sessionlists.d.ts +21 -0
- package/dist_ts_chat_harness_server/classes.sessionlists.js +70 -0
- package/dist_ts_chat_harness_server/classes.turngate.d.ts +38 -0
- package/dist_ts_chat_harness_server/classes.turngate.js +207 -0
- package/dist_ts_chat_harness_server/errors.d.ts +5 -0
- package/dist_ts_chat_harness_server/errors.js +9 -0
- package/dist_ts_chat_harness_server/functions.errorprojector.d.ts +11 -0
- package/dist_ts_chat_harness_server/functions.errorprojector.js +32 -0
- package/dist_ts_chat_harness_server/functions.validation.d.ts +18 -0
- package/dist_ts_chat_harness_server/functions.validation.js +88 -0
- package/dist_ts_chat_harness_server/index.d.ts +4 -0
- package/dist_ts_chat_harness_server/index.js +4 -0
- package/dist_ts_chat_harness_server/interfaces.d.ts +67 -0
- package/dist_ts_chat_harness_server/interfaces.js +2 -0
- package/dist_ts_chat_harness_server/plugins.d.ts +7 -0
- package/dist_ts_chat_harness_server/plugins.js +6 -0
- package/dist_ts_chat_harness_typedsocket/classes.clienttransport.d.ts +22 -0
- package/dist_ts_chat_harness_typedsocket/classes.clienttransport.js +55 -0
- package/dist_ts_chat_harness_typedsocket/classes.serverbinding.d.ts +46 -0
- package/dist_ts_chat_harness_typedsocket/classes.serverbinding.js +110 -0
- package/dist_ts_chat_harness_typedsocket/index.d.ts +3 -0
- package/dist_ts_chat_harness_typedsocket/index.js +3 -0
- package/dist_ts_chat_harness_typedsocket/interfaces.requests.d.ts +38 -0
- package/dist_ts_chat_harness_typedsocket/interfaces.requests.js +2 -0
- package/dist_ts_chat_harness_typedsocket/plugins.d.ts +9 -0
- package/dist_ts_chat_harness_typedsocket/plugins.js +8 -0
- package/dist_ts_tools/tool.filesystem.d.ts +2 -0
- package/dist_ts_tools/tool.filesystem.js +42 -1
- package/dist_ts_tools_node/classes.workspacereversion.d.ts +48 -0
- package/dist_ts_tools_node/classes.workspacereversion.js +202 -0
- package/dist_ts_tools_node/functions.fileimages.d.ts +22 -0
- package/dist_ts_tools_node/functions.fileimages.js +87 -0
- package/dist_ts_tools_node/index.d.ts +3 -1
- package/dist_ts_tools_node/index.js +2 -1
- package/dist_ts_tools_node/plugins.d.ts +1 -0
- package/dist_ts_tools_node/tool.context.d.ts +20 -0
- package/dist_ts_tools_node/tool.context.js +25 -4
- package/package.json +28 -4
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/errors.ts +10 -0
- package/ts/functions.toolpermissions.ts +60 -0
- package/ts/index.ts +6 -0
- package/ts/plugins.ts +1 -0
- package/ts_chat_harness/classes.chatclient.ts +343 -0
- package/ts_chat_harness/classes.chatrequesterror.ts +7 -0
- package/ts_chat_harness/classes.transcript.ts +1 -1
- package/ts_chat_harness/functions.intents.ts +2 -2
- package/ts_chat_harness/functions.mapping.ts +1 -1
- package/ts_chat_harness/index.ts +20 -0
- package/ts_chat_harness/interfaces.chat.ts +108 -0
- package/ts_chat_harness/readme.md +24 -0
- package/ts_chat_harness_server/classes.chathub.ts +400 -0
- package/ts_chat_harness_server/classes.chatsubscription.ts +257 -0
- package/ts_chat_harness_server/classes.sessionlists.ts +90 -0
- package/ts_chat_harness_server/classes.turngate.ts +247 -0
- package/ts_chat_harness_server/errors.ts +9 -0
- package/ts_chat_harness_server/functions.errorprojector.ts +40 -0
- package/ts_chat_harness_server/functions.validation.ts +104 -0
- package/ts_chat_harness_server/index.ts +19 -0
- package/ts_chat_harness_server/interfaces.ts +92 -0
- package/ts_chat_harness_server/plugins.ts +62 -0
- package/ts_chat_harness_server/readme.md +91 -0
- package/ts_chat_harness_typedsocket/classes.clienttransport.ts +87 -0
- package/ts_chat_harness_typedsocket/classes.serverbinding.ts +172 -0
- package/ts_chat_harness_typedsocket/index.ts +17 -0
- package/ts_chat_harness_typedsocket/interfaces.requests.ts +40 -0
- package/ts_chat_harness_typedsocket/plugins.ts +28 -0
- package/ts_chat_harness_typedsocket/readme.md +57 -0
- package/ts_tools/readme.md +10 -0
- package/ts_tools/tool.filesystem.ts +55 -0
- package/ts_tools_node/classes.workspacereversion.ts +255 -0
- package/ts_tools_node/functions.fileimages.ts +89 -0
- package/ts_tools_node/index.ts +7 -1
- package/ts_tools_node/plugins.ts +11 -0
- package/ts_tools_node/readme.md +27 -0
- package/ts_tools_node/tool.context.ts +49 -3
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
import type { IToolFileChange, IToolFileChangeObserver } from './tool.context.js';
|
|
3
|
+
import {
|
|
4
|
+
fileImageBytes,
|
|
5
|
+
fileImagesEqual,
|
|
6
|
+
missingAncestors,
|
|
7
|
+
readFileImage,
|
|
8
|
+
removeEmptyDirectories,
|
|
9
|
+
writeFileImage,
|
|
10
|
+
type TFileImage,
|
|
11
|
+
} from './functions.fileimages.js';
|
|
12
|
+
|
|
13
|
+
export interface IInMemoryWorkspaceReversionOptions {
|
|
14
|
+
/** The workspace a revertible turn names in the session's reversion info. */
|
|
15
|
+
workspace: plugins.IFlexAffectedWorkspace;
|
|
16
|
+
/**
|
|
17
|
+
* The bytes of file images one turn may hold. A turn that changes more is recorded as
|
|
18
|
+
* nonrevertible (`capture-too-large`) and its images are dropped. Default: 64 MiB.
|
|
19
|
+
*/
|
|
20
|
+
maxCaptureBytes?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** One file a turn changed: what it held before the turn's first change and after the turn. */
|
|
24
|
+
interface IFileRecord {
|
|
25
|
+
before: TFileImage;
|
|
26
|
+
after?: TFileImage;
|
|
27
|
+
/** directories the turn's first write created, deepest first */
|
|
28
|
+
createdDirectories: string[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ICapture {
|
|
32
|
+
captureId: string;
|
|
33
|
+
runId: string;
|
|
34
|
+
state: 'prepared' | 'finalized';
|
|
35
|
+
files: Map<string, IFileRecord>;
|
|
36
|
+
bytes: number;
|
|
37
|
+
nonrevertibleReason?: string;
|
|
38
|
+
outcome?: plugins.TFlexTurnReversionFinalizedOutcome;
|
|
39
|
+
appliedOperations: Set<string>;
|
|
40
|
+
/** apply operations that failed after a partial change could not be rolled back */
|
|
41
|
+
uncertainOperations: Set<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ICaptureReference {
|
|
45
|
+
captureId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const DEFAULT_MAX_CAPTURE_BYTES = 64 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
const isCaptureReference = (valueArg: plugins.TJsonValue): valueArg is plugins.TJsonValue & ICaptureReference =>
|
|
51
|
+
!!valueArg
|
|
52
|
+
&& typeof valueArg === 'object'
|
|
53
|
+
&& !Array.isArray(valueArg)
|
|
54
|
+
&& typeof valueArg.captureId === 'string';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Turn reversion for the local filesystem tools, held in memory: FlexHarness `/undo` and `/redo`
|
|
58
|
+
* move a turn's file changes together with its transcript.
|
|
59
|
+
*
|
|
60
|
+
* Give each run's `createLocalToolExecutionContext` the observer `observerFor(context.runId)`. The
|
|
61
|
+
* first change of a file in a turn records what the file held before; `finalize` records what it
|
|
62
|
+
* holds after. Undo puts the before images back and redo the after images, each only while every
|
|
63
|
+
* file still holds what the other direction left, so a later edit of the user is never overwritten.
|
|
64
|
+
* A turn that ran a shell command is nonrevertible, since the command may have changed files no
|
|
65
|
+
* observer saw.
|
|
66
|
+
*
|
|
67
|
+
* Captures live as long as the process. A capture FlexHarness asks about after a restart is
|
|
68
|
+
* `missing`, and undoing past it fails without changing anything. Only root-session runs are
|
|
69
|
+
* captured; a change of a run without a prepared capture is refused.
|
|
70
|
+
*/
|
|
71
|
+
export class InMemoryWorkspaceReversion<TScope = unknown> implements plugins.IFlexTurnReversionProviderV2<TScope> {
|
|
72
|
+
public readonly protocolVersion = 2;
|
|
73
|
+
|
|
74
|
+
private readonly workspace: plugins.IFlexAffectedWorkspace;
|
|
75
|
+
private readonly maxCaptureBytes: number;
|
|
76
|
+
private readonly captures = new Map<string, ICapture>();
|
|
77
|
+
private readonly captureIdsByRun = new Map<string, string>();
|
|
78
|
+
|
|
79
|
+
constructor(optionsArg: IInMemoryWorkspaceReversionOptions) {
|
|
80
|
+
this.workspace = { id: optionsArg.workspace.id, label: optionsArg.workspace.label };
|
|
81
|
+
this.maxCaptureBytes = optionsArg.maxCaptureBytes ?? DEFAULT_MAX_CAPTURE_BYTES;
|
|
82
|
+
if (!Number.isSafeInteger(this.maxCaptureBytes) || this.maxCaptureBytes <= 0) {
|
|
83
|
+
throw new Error('maxCaptureBytes must be a positive integer.');
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The observer of one run's file changes and shell commands. */
|
|
88
|
+
public observerFor(runIdArg: string): IToolFileChangeObserver {
|
|
89
|
+
return {
|
|
90
|
+
beforeFileChange: (changeArg) => this.recordChange(runIdArg, changeArg),
|
|
91
|
+
beforeShellCommand: () => {
|
|
92
|
+
this.markNonrevertible(this.requirePreparedCapture(runIdArg), 'shell-command');
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public prepare(contextArg: plugins.IFlexTurnReversionBaseContext<TScope>): void {
|
|
98
|
+
contextArg.signal.throwIfAborted();
|
|
99
|
+
if (this.captures.has(contextArg.captureId)) return;
|
|
100
|
+
this.captures.set(contextArg.captureId, {
|
|
101
|
+
captureId: contextArg.captureId,
|
|
102
|
+
runId: contextArg.runId,
|
|
103
|
+
state: 'prepared',
|
|
104
|
+
files: new Map(),
|
|
105
|
+
bytes: 0,
|
|
106
|
+
appliedOperations: new Set(),
|
|
107
|
+
uncertainOperations: new Set(),
|
|
108
|
+
});
|
|
109
|
+
this.captureIdsByRun.set(contextArg.runId, contextArg.captureId);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
public inspectCapture(
|
|
113
|
+
contextArg: plugins.IFlexTurnReversionBaseContext<TScope>,
|
|
114
|
+
): plugins.TFlexTurnReversionCaptureInspectionV2 {
|
|
115
|
+
const capture = this.captures.get(contextArg.captureId);
|
|
116
|
+
if (!capture) return { status: 'missing' };
|
|
117
|
+
return capture.outcome ? { status: 'finalized', outcome: capture.outcome } : { status: 'prepared' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
public async finalize(
|
|
121
|
+
contextArg: plugins.IFlexTurnReversionBaseContext<TScope>,
|
|
122
|
+
): Promise<plugins.TFlexTurnReversionFinalizedOutcome> {
|
|
123
|
+
contextArg.signal.throwIfAborted();
|
|
124
|
+
const capture = this.requireCapture(contextArg.captureId);
|
|
125
|
+
if (capture.outcome) return capture.outcome;
|
|
126
|
+
const reference: ICaptureReference = { captureId: capture.captureId };
|
|
127
|
+
if (!capture.nonrevertibleReason) {
|
|
128
|
+
for (const [filePath, record] of capture.files) {
|
|
129
|
+
record.after = await readFileImage(filePath);
|
|
130
|
+
if (fileImagesEqual(record.before, record.after)) capture.files.delete(filePath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
capture.state = 'finalized';
|
|
134
|
+
if (capture.nonrevertibleReason) {
|
|
135
|
+
capture.outcome = {
|
|
136
|
+
disposition: 'nonrevertible',
|
|
137
|
+
reference: { ...reference },
|
|
138
|
+
reasonCode: capture.nonrevertibleReason,
|
|
139
|
+
affectedWorkspaces: [this.workspace],
|
|
140
|
+
};
|
|
141
|
+
} else if (capture.files.size === 0) {
|
|
142
|
+
capture.outcome = { disposition: 'no-change', reference: { ...reference } };
|
|
143
|
+
} else {
|
|
144
|
+
capture.outcome = { disposition: 'revertible', reference: { ...reference }, affectedWorkspaces: [this.workspace] };
|
|
145
|
+
}
|
|
146
|
+
this.captureIdsByRun.delete(capture.runId);
|
|
147
|
+
return capture.outcome;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public async apply(contextArg: plugins.IFlexTurnReversionApplyContext<TScope>): Promise<void> {
|
|
151
|
+
contextArg.signal.throwIfAborted();
|
|
152
|
+
const capture = this.captureOf(contextArg.reference);
|
|
153
|
+
if (capture.outcome?.disposition !== 'revertible') {
|
|
154
|
+
throw new Error('This turn has no revertible workspace changes.');
|
|
155
|
+
}
|
|
156
|
+
const records = [...capture.files.entries()];
|
|
157
|
+
const expected = (recordArg: IFileRecord) => (contextArg.direction === 'undo' ? recordArg.after! : recordArg.before);
|
|
158
|
+
const target = (recordArg: IFileRecord) => (contextArg.direction === 'undo' ? recordArg.before : recordArg.after!);
|
|
159
|
+
for (const [filePath, record] of records) {
|
|
160
|
+
if (!fileImagesEqual(await readFileImage(filePath), expected(record))) {
|
|
161
|
+
throw new Error(`"${filePath}" changed after the turn; its changes were not ${contextArg.direction === 'undo' ? 'undone' : 'redone'}.`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const written: Array<[string, IFileRecord]> = [];
|
|
165
|
+
try {
|
|
166
|
+
for (const entry of records) {
|
|
167
|
+
await writeFileImage(entry[0], target(entry[1]));
|
|
168
|
+
written.push(entry);
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
await this.rollBack(written, expected, contextArg.operationId, capture);
|
|
172
|
+
throw error;
|
|
173
|
+
}
|
|
174
|
+
if (contextArg.direction === 'undo') {
|
|
175
|
+
for (const [, record] of records) await removeEmptyDirectories(record.createdDirectories);
|
|
176
|
+
}
|
|
177
|
+
capture.appliedOperations.add(contextArg.operationId);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public inspectApply(
|
|
181
|
+
contextArg: plugins.IFlexTurnReversionApplyContext<TScope>,
|
|
182
|
+
): plugins.IFlexTurnReversionApplyInspection {
|
|
183
|
+
const capture = this.captures.get(isCaptureReference(contextArg.reference) ? contextArg.reference.captureId : '');
|
|
184
|
+
if (!capture) return { status: 'not-applied' };
|
|
185
|
+
if (capture.appliedOperations.has(contextArg.operationId)) return { status: 'applied' };
|
|
186
|
+
if (capture.uncertainOperations.has(contextArg.operationId)) return { status: 'unknown' };
|
|
187
|
+
return { status: 'not-applied' };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
public release(contextArg: plugins.IFlexTurnReversionReleaseContext<TScope>): void {
|
|
191
|
+
const captureId = isCaptureReference(contextArg.reference) ? contextArg.reference.captureId : contextArg.captureId;
|
|
192
|
+
const capture = this.captures.get(captureId);
|
|
193
|
+
if (!capture) return;
|
|
194
|
+
this.captures.delete(captureId);
|
|
195
|
+
if (this.captureIdsByRun.get(capture.runId) === captureId) this.captureIdsByRun.delete(capture.runId);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async recordChange(runIdArg: string, changeArg: IToolFileChange): Promise<void> {
|
|
199
|
+
const capture = this.requirePreparedCapture(runIdArg);
|
|
200
|
+
if (capture.nonrevertibleReason || capture.files.has(changeArg.path)) return;
|
|
201
|
+
const before = await readFileImage(changeArg.path);
|
|
202
|
+
const createdDirectories = before.kind === 'absent' && changeArg.kind === 'write'
|
|
203
|
+
? await missingAncestors(changeArg.path)
|
|
204
|
+
: [];
|
|
205
|
+
if (capture.files.has(changeArg.path)) return;
|
|
206
|
+
capture.bytes += fileImageBytes(before);
|
|
207
|
+
if (capture.bytes > this.maxCaptureBytes) {
|
|
208
|
+
this.markNonrevertible(capture, 'capture-too-large');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
capture.files.set(changeArg.path, { before, createdDirectories });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private markNonrevertible(captureArg: ICapture, reasonArg: string): void {
|
|
215
|
+
captureArg.nonrevertibleReason ??= reasonArg;
|
|
216
|
+
captureArg.files.clear();
|
|
217
|
+
captureArg.bytes = 0;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async rollBack(
|
|
221
|
+
writtenArg: Array<[string, IFileRecord]>,
|
|
222
|
+
expectedArg: (recordArg: IFileRecord) => TFileImage,
|
|
223
|
+
operationIdArg: string,
|
|
224
|
+
captureArg: ICapture,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
try {
|
|
227
|
+
for (const [filePath, record] of writtenArg.reverse()) await writeFileImage(filePath, expectedArg(record));
|
|
228
|
+
} catch {
|
|
229
|
+
// the workspace holds part of the change; FlexHarness learns that the outcome is unknown
|
|
230
|
+
captureArg.uncertainOperations.add(operationIdArg);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private requirePreparedCapture(runIdArg: string): ICapture {
|
|
235
|
+
const captureId = this.captureIdsByRun.get(runIdArg);
|
|
236
|
+
const capture = captureId ? this.captures.get(captureId) : undefined;
|
|
237
|
+
if (!capture || capture.state !== 'prepared') {
|
|
238
|
+
throw new Error(`No workspace capture is open for run "${runIdArg}"; the change was refused.`);
|
|
239
|
+
}
|
|
240
|
+
return capture;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private requireCapture(captureIdArg: string): ICapture {
|
|
244
|
+
const capture = this.captures.get(captureIdArg);
|
|
245
|
+
if (!capture) throw new Error(`The workspace capture "${captureIdArg}" is not held.`);
|
|
246
|
+
return capture;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private captureOf(referenceArg: plugins.TJsonValue): ICapture {
|
|
250
|
+
if (!isCaptureReference(referenceArg)) throw new Error('The workspace capture reference is malformed.');
|
|
251
|
+
const capture = this.captures.get(referenceArg.captureId);
|
|
252
|
+
if (!capture) throw new Error('The workspace changes of this turn are no longer held in memory.');
|
|
253
|
+
return capture;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as plugins from './plugins.js';
|
|
2
|
+
|
|
3
|
+
/** What one path holds at a moment: nothing, a regular file, or a symbolic link. */
|
|
4
|
+
export type TFileImage =
|
|
5
|
+
| { kind: 'absent' }
|
|
6
|
+
| { kind: 'file'; bytes: Buffer; mode: number }
|
|
7
|
+
| { kind: 'symlink'; target: string };
|
|
8
|
+
|
|
9
|
+
const isMissing = (error: unknown): boolean => {
|
|
10
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
11
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Reads what the path holds, without following a final symbolic link. */
|
|
15
|
+
export const readFileImage = async (pathArg: string): Promise<TFileImage> => {
|
|
16
|
+
let stats: plugins.fs.Stats;
|
|
17
|
+
try {
|
|
18
|
+
stats = await plugins.fs.promises.lstat(pathArg);
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (isMissing(error)) return { kind: 'absent' };
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
if (stats.isSymbolicLink()) return { kind: 'symlink', target: await plugins.fs.promises.readlink(pathArg) };
|
|
24
|
+
if (stats.isFile()) return { kind: 'file', bytes: await plugins.fs.promises.readFile(pathArg), mode: stats.mode & 0o7777 };
|
|
25
|
+
throw new Error(`"${pathArg}" is neither a file nor a symbolic link.`);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The bytes an image keeps in memory. */
|
|
29
|
+
export const fileImageBytes = (imageArg: TFileImage): number =>
|
|
30
|
+
imageArg.kind === 'file'
|
|
31
|
+
? imageArg.bytes.byteLength
|
|
32
|
+
: imageArg.kind === 'symlink'
|
|
33
|
+
? Buffer.byteLength(imageArg.target)
|
|
34
|
+
: 0;
|
|
35
|
+
|
|
36
|
+
export const fileImagesEqual = (leftArg: TFileImage, rightArg: TFileImage): boolean => {
|
|
37
|
+
if (leftArg.kind === 'absent' || rightArg.kind === 'absent') return leftArg.kind === rightArg.kind;
|
|
38
|
+
if (leftArg.kind === 'symlink' || rightArg.kind === 'symlink') {
|
|
39
|
+
return leftArg.kind === 'symlink' && rightArg.kind === 'symlink' && leftArg.target === rightArg.target;
|
|
40
|
+
}
|
|
41
|
+
return leftArg.bytes.equals(rightArg.bytes);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Puts the image at the path, creating parent directories as needed. */
|
|
45
|
+
export const writeFileImage = async (pathArg: string, imageArg: TFileImage): Promise<void> => {
|
|
46
|
+
if (imageArg.kind === 'absent') {
|
|
47
|
+
await plugins.fs.promises.rm(pathArg, { force: true });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
await plugins.fs.promises.mkdir(plugins.path.dirname(pathArg), { recursive: true });
|
|
51
|
+
if (imageArg.kind === 'symlink') {
|
|
52
|
+
await plugins.fs.promises.rm(pathArg, { force: true });
|
|
53
|
+
await plugins.fs.promises.symlink(imageArg.target, pathArg);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if ((await readFileImage(pathArg)).kind === 'symlink') await plugins.fs.promises.rm(pathArg);
|
|
57
|
+
await plugins.fs.promises.writeFile(pathArg, imageArg.bytes);
|
|
58
|
+
await plugins.fs.promises.chmod(pathArg, imageArg.mode);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** The ancestors of the path that do not exist yet, deepest first: a write would create them. */
|
|
62
|
+
export const missingAncestors = async (pathArg: string): Promise<string[]> => {
|
|
63
|
+
const missing: string[] = [];
|
|
64
|
+
let current = plugins.path.dirname(pathArg);
|
|
65
|
+
while (current !== plugins.path.dirname(current)) {
|
|
66
|
+
try {
|
|
67
|
+
await plugins.fs.promises.lstat(current);
|
|
68
|
+
break;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!isMissing(error)) throw error;
|
|
71
|
+
missing.push(current);
|
|
72
|
+
current = plugins.path.dirname(current);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return missing;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Removes the directories, deepest first, as far as they are empty. */
|
|
79
|
+
export const removeEmptyDirectories = async (directoriesArg: readonly string[]): Promise<void> => {
|
|
80
|
+
for (const directory of directoriesArg) {
|
|
81
|
+
try {
|
|
82
|
+
await plugins.fs.promises.rmdir(directory);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
85
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST') return;
|
|
86
|
+
if (!isMissing(error)) throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
package/ts_tools_node/index.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
export { createLocalToolExecutionContext } from './tool.context.js';
|
|
2
|
-
export type {
|
|
2
|
+
export type {
|
|
3
|
+
ILocalToolExecutionContextOptions,
|
|
4
|
+
IToolFileChange,
|
|
5
|
+
IToolFileChangeObserver,
|
|
6
|
+
} from './tool.context.js';
|
|
7
|
+
export { InMemoryWorkspaceReversion } from './classes.workspacereversion.js';
|
|
8
|
+
export type { IInMemoryWorkspaceReversionOptions } from './classes.workspacereversion.js';
|
|
3
9
|
export { FileToolJobStore } from './tool.persistence.js';
|
|
4
10
|
export type { IFileToolJobStoreOptions } from './tool.persistence.js';
|
|
5
11
|
export * from './tools.local.js';
|
package/ts_tools_node/plugins.ts
CHANGED
|
@@ -41,3 +41,14 @@ export type {
|
|
|
41
41
|
TToolJobEvent,
|
|
42
42
|
TToolJobEventListener,
|
|
43
43
|
} from '@modelprofile.com/flexharness-agent';
|
|
44
|
+
export type {
|
|
45
|
+
IFlexAffectedWorkspace,
|
|
46
|
+
IFlexTurnReversionApplyContext,
|
|
47
|
+
IFlexTurnReversionApplyInspection,
|
|
48
|
+
IFlexTurnReversionBaseContext,
|
|
49
|
+
IFlexTurnReversionProviderV2,
|
|
50
|
+
IFlexTurnReversionReleaseContext,
|
|
51
|
+
TFlexTurnReversionCaptureInspectionV2,
|
|
52
|
+
TFlexTurnReversionFinalizedOutcome,
|
|
53
|
+
TJsonValue,
|
|
54
|
+
} from '@modelprofile.com/flexharness';
|
package/ts_tools_node/readme.md
CHANGED
|
@@ -11,6 +11,33 @@ pnpm add @modelprofile.com/flexharness
|
|
|
11
11
|
See the [toolbox usage and migration guide](https://code.foss.global/modelprofile.com/flexharness#readme)
|
|
12
12
|
for composition examples and the SmartAI/SmartAgent migration map.
|
|
13
13
|
|
|
14
|
+
## Turn reversion for local files
|
|
15
|
+
|
|
16
|
+
`InMemoryWorkspaceReversion` is a FlexHarness `turnReversionProvider` that undoes and redoes a turn's changes of the local filesystem tools together with its transcript, held in memory for the process's lifetime.
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
const reversion = new InMemoryWorkspaceReversion({ workspace: { id: rootDir, label: 'project' } });
|
|
20
|
+
const harness = new FlexHarness({
|
|
21
|
+
scopeResolver,
|
|
22
|
+
modelResolver,
|
|
23
|
+
turnReversionProvider: reversion,
|
|
24
|
+
reversionPolicy: 'workspace-required',
|
|
25
|
+
toolProvider: {
|
|
26
|
+
provideTools: (context) => {
|
|
27
|
+
const execution = createLocalToolExecutionContext({
|
|
28
|
+
rootDir,
|
|
29
|
+
cwd: rootDir,
|
|
30
|
+
fileChangeObserver: reversion.observerFor(context.runId),
|
|
31
|
+
requestPermission: createFlexToolPermissionRequester(context, { policy: { write: 'allow' } }),
|
|
32
|
+
});
|
|
33
|
+
return { tools: { ...createFilesystemTools(execution, { includeEdit: true }), ...createShellTools(execution) } };
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The `fileChangeObserver` of `createLocalToolExecutionContext` sees every write and delete by its canonical path (a write follows a symbolic link to its target, a delete removes the link itself) after the root checks and before the change, and every shell command before it starts; a rejection refuses the operation. The first change of a file in a turn records its bytes, mode or link target, and the directories a new file's write creates. Undo puts the before images back and removes those directories while empty; redo puts the after images back. Either direction applies only while every file still holds what the other direction left, so a later edit is never overwritten: the undo fails without a change and can be retried. A turn that ran a shell command is nonrevertible (`shell-command`), as is one whose images exceed `maxCaptureBytes` (64 MiB by default; `capture-too-large`). Only root-session runs are captured: a change of a run without a capture is refused. Captures do not survive a restart; FlexHarness then finds them `missing`.
|
|
40
|
+
|
|
14
41
|
## Issue Reporting and Security
|
|
15
42
|
|
|
16
43
|
Report issues and security concerns through [community.foss.global](https://community.foss.global).
|
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
import * as plugins from './plugins.js';
|
|
2
2
|
import type { IToolJobStore, IToolPermissionRequest, IToolInvocationOptions, IToolRunOptions, IToolShellResult, IShellCommand, IToolShellContext, IToolJobRequest, IShellStartedResult, IShellFinishedResult, IToolJobState, IToolJobHandle, IToolJobEvent, IToolJobOutputEvent, TToolJobEvent, TToolJobEventListener, IToolJobContext, IToolFilesystemReadOptions, IToolFilesystemListOptions, IToolFilesystemContext, IToolBrowserRunOptions, TBrowserToolAction, IBrowserToolInput, IToolBrowserContext, IToolExecutionContext } from './plugins.js';
|
|
3
3
|
|
|
4
|
+
/** A file a tool is about to change, named by its canonical path. */
|
|
5
|
+
export interface IToolFileChange {
|
|
6
|
+
kind: 'write' | 'delete';
|
|
7
|
+
/**
|
|
8
|
+
* The canonical absolute path the operation changes. A write follows a symlink to its target; a
|
|
9
|
+
* delete removes the link itself, so its path is the link's.
|
|
10
|
+
*/
|
|
11
|
+
path: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Sees every change the local filesystem tools make, after the path checks and before the change,
|
|
16
|
+
* and every shell command before it starts. A rejection refuses the operation.
|
|
17
|
+
*/
|
|
18
|
+
export interface IToolFileChangeObserver {
|
|
19
|
+
beforeFileChange(change: IToolFileChange): Promise<void> | void;
|
|
20
|
+
/** A shell command or background job may change files the observer does not see. */
|
|
21
|
+
beforeShellCommand(): Promise<void> | void;
|
|
22
|
+
}
|
|
23
|
+
|
|
4
24
|
export interface ILocalToolExecutionContextOptions {
|
|
5
25
|
cwd?: string;
|
|
6
26
|
rootDir?: string;
|
|
@@ -9,6 +29,8 @@ export interface ILocalToolExecutionContextOptions {
|
|
|
9
29
|
abortSignal?: AbortSignal;
|
|
10
30
|
/** Guards each local filesystem operation or shell side effect. */
|
|
11
31
|
beforeOperation?: () => Promise<void> | void;
|
|
32
|
+
/** Observes every file change and shell command, for example to capture a turn for undo. */
|
|
33
|
+
fileChangeObserver?: IToolFileChangeObserver;
|
|
12
34
|
/** Hard combined stdout/stderr capture limit for each local shell command. Default: 1 MiB. */
|
|
13
35
|
maxShellOutputBytes?: number;
|
|
14
36
|
/** Number of terminal background job snapshots retained for get/list. Default: 100. */
|
|
@@ -58,6 +80,13 @@ export const createLocalToolExecutionContext = (options: ILocalToolExecutionCont
|
|
|
58
80
|
if (!Number.isSafeInteger(jobStoreOperationTimeoutMs) || jobStoreOperationTimeoutMs <= 0) {
|
|
59
81
|
throw new Error('jobStoreOperationTimeoutMs must be a positive integer.');
|
|
60
82
|
}
|
|
83
|
+
const fileChangeObserver = options.fileChangeObserver;
|
|
84
|
+
const beforeShellOperation: ILocalToolExecutionContextOptions['beforeOperation'] = fileChangeObserver
|
|
85
|
+
? async () => {
|
|
86
|
+
await options.beforeOperation?.();
|
|
87
|
+
await fileChangeObserver.beforeShellCommand();
|
|
88
|
+
}
|
|
89
|
+
: options.beforeOperation;
|
|
61
90
|
const jobs = createLocalToolJobContext({
|
|
62
91
|
cwd,
|
|
63
92
|
rootDir,
|
|
@@ -67,7 +96,7 @@ export const createLocalToolExecutionContext = (options: ILocalToolExecutionCont
|
|
|
67
96
|
outputListenerTimeoutMs,
|
|
68
97
|
jobStoreOperationTimeoutMs,
|
|
69
98
|
settleOrphanedJob: options.settleOrphanedJob,
|
|
70
|
-
beforeOperation:
|
|
99
|
+
beforeOperation: beforeShellOperation,
|
|
71
100
|
abortSignal: options.abortSignal,
|
|
72
101
|
});
|
|
73
102
|
return {
|
|
@@ -84,7 +113,7 @@ export const createLocalToolExecutionContext = (options: ILocalToolExecutionCont
|
|
|
84
113
|
onOutput: runOptions?.onOutput,
|
|
85
114
|
outputListenerTimeoutMs,
|
|
86
115
|
maxOutputBytes: maxShellOutputBytes,
|
|
87
|
-
beforeOperation:
|
|
116
|
+
beforeOperation: beforeShellOperation,
|
|
88
117
|
}),
|
|
89
118
|
runStructured: (command, runOptions) => runLocalStructuredCommand(command, {
|
|
90
119
|
cwd: resolveLocalPath(runOptions?.cwd ?? cwd, rootDir),
|
|
@@ -93,7 +122,7 @@ export const createLocalToolExecutionContext = (options: ILocalToolExecutionCont
|
|
|
93
122
|
onOutput: runOptions?.onOutput,
|
|
94
123
|
outputListenerTimeoutMs,
|
|
95
124
|
maxOutputBytes: maxShellOutputBytes,
|
|
96
|
-
beforeOperation:
|
|
125
|
+
beforeOperation: beforeShellOperation,
|
|
97
126
|
}),
|
|
98
127
|
},
|
|
99
128
|
jobs,
|
|
@@ -147,6 +176,13 @@ export const createLocalToolExecutionContext = (options: ILocalToolExecutionCont
|
|
|
147
176
|
canonicalBoundary.rootDir,
|
|
148
177
|
);
|
|
149
178
|
}
|
|
179
|
+
if (fileChangeObserver) {
|
|
180
|
+
await fileChangeObserver.beforeFileChange({
|
|
181
|
+
kind: 'write',
|
|
182
|
+
path: await resolveCanonicalProspectivePath(resolved),
|
|
183
|
+
});
|
|
184
|
+
throwIfAborted(abortSignal);
|
|
185
|
+
}
|
|
150
186
|
await runBeforeLocalOperation(options.beforeOperation, abortSignal);
|
|
151
187
|
await plugins.fs.promises.mkdir(parentPath, { recursive: true });
|
|
152
188
|
throwIfAborted(abortSignal);
|
|
@@ -193,6 +229,16 @@ export const createLocalToolExecutionContext = (options: ILocalToolExecutionCont
|
|
|
193
229
|
if (rootDir) {
|
|
194
230
|
await resolveCanonicalExistingLocalPath(resolved, targetPath, rootDir);
|
|
195
231
|
}
|
|
232
|
+
if (fileChangeObserver) {
|
|
233
|
+
await fileChangeObserver.beforeFileChange({
|
|
234
|
+
kind: 'delete',
|
|
235
|
+
path: plugins.path.join(
|
|
236
|
+
await resolveCanonicalProspectivePath(plugins.path.dirname(resolved)),
|
|
237
|
+
plugins.path.basename(resolved),
|
|
238
|
+
),
|
|
239
|
+
});
|
|
240
|
+
throwIfAborted(abortSignal);
|
|
241
|
+
}
|
|
196
242
|
await runBeforeLocalOperation(options.beforeOperation, abortSignal);
|
|
197
243
|
await plugins.fs.promises.rm(resolved, { recursive: false, force: false });
|
|
198
244
|
throwIfAborted(abortSignal);
|