@modelprofile.com/flexharness 10.17.1 → 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.
Files changed (104) hide show
  1. package/dist_ts/00_commitinfo_data.js +1 -1
  2. package/dist_ts/errors.d.ts +4 -0
  3. package/dist_ts/errors.js +9 -1
  4. package/dist_ts/functions.toolpermissions.d.ts +24 -0
  5. package/dist_ts/functions.toolpermissions.js +38 -0
  6. package/dist_ts/index.d.ts +1 -0
  7. package/dist_ts/index.js +2 -1
  8. package/dist_ts/plugins.d.ts +1 -1
  9. package/dist_ts/plugins.js +1 -1
  10. package/dist_ts_chat_harness/classes.chatclient.d.ts +95 -0
  11. package/dist_ts_chat_harness/classes.chatclient.js +263 -0
  12. package/dist_ts_chat_harness/classes.chatrequesterror.d.ts +4 -0
  13. package/dist_ts_chat_harness/classes.chatrequesterror.js +8 -0
  14. package/dist_ts_chat_harness/classes.transcript.d.ts +1 -1
  15. package/dist_ts_chat_harness/classes.transcript.js +1 -1
  16. package/dist_ts_chat_harness/functions.intents.d.ts +1 -2
  17. package/dist_ts_chat_harness/functions.intents.js +1 -1
  18. package/dist_ts_chat_harness/functions.mapping.d.ts +1 -1
  19. package/dist_ts_chat_harness/functions.mapping.js +1 -1
  20. package/dist_ts_chat_harness/index.d.ts +3 -0
  21. package/dist_ts_chat_harness/index.js +3 -1
  22. package/dist_ts_chat_harness/interfaces.chat.d.ts +136 -0
  23. package/dist_ts_chat_harness/interfaces.chat.js +2 -0
  24. package/dist_ts_chat_harness/plugins.js +1 -1
  25. package/dist_ts_chat_harness_server/classes.chathub.d.ts +78 -0
  26. package/dist_ts_chat_harness_server/classes.chathub.js +332 -0
  27. package/dist_ts_chat_harness_server/classes.chatsubscription.d.ts +55 -0
  28. package/dist_ts_chat_harness_server/classes.chatsubscription.js +236 -0
  29. package/dist_ts_chat_harness_server/classes.sessionlists.d.ts +21 -0
  30. package/dist_ts_chat_harness_server/classes.sessionlists.js +70 -0
  31. package/dist_ts_chat_harness_server/classes.turngate.d.ts +38 -0
  32. package/dist_ts_chat_harness_server/classes.turngate.js +207 -0
  33. package/dist_ts_chat_harness_server/errors.d.ts +5 -0
  34. package/dist_ts_chat_harness_server/errors.js +9 -0
  35. package/dist_ts_chat_harness_server/functions.errorprojector.d.ts +11 -0
  36. package/dist_ts_chat_harness_server/functions.errorprojector.js +32 -0
  37. package/dist_ts_chat_harness_server/functions.validation.d.ts +18 -0
  38. package/dist_ts_chat_harness_server/functions.validation.js +88 -0
  39. package/dist_ts_chat_harness_server/index.d.ts +4 -0
  40. package/dist_ts_chat_harness_server/index.js +4 -0
  41. package/dist_ts_chat_harness_server/interfaces.d.ts +67 -0
  42. package/dist_ts_chat_harness_server/interfaces.js +2 -0
  43. package/dist_ts_chat_harness_server/plugins.d.ts +7 -0
  44. package/dist_ts_chat_harness_server/plugins.js +6 -0
  45. package/dist_ts_chat_harness_typedsocket/classes.clienttransport.d.ts +22 -0
  46. package/dist_ts_chat_harness_typedsocket/classes.clienttransport.js +55 -0
  47. package/dist_ts_chat_harness_typedsocket/classes.serverbinding.d.ts +46 -0
  48. package/dist_ts_chat_harness_typedsocket/classes.serverbinding.js +110 -0
  49. package/dist_ts_chat_harness_typedsocket/index.d.ts +3 -0
  50. package/dist_ts_chat_harness_typedsocket/index.js +3 -0
  51. package/dist_ts_chat_harness_typedsocket/interfaces.requests.d.ts +38 -0
  52. package/dist_ts_chat_harness_typedsocket/interfaces.requests.js +2 -0
  53. package/dist_ts_chat_harness_typedsocket/plugins.d.ts +9 -0
  54. package/dist_ts_chat_harness_typedsocket/plugins.js +8 -0
  55. package/dist_ts_tools/tool.filesystem.d.ts +2 -0
  56. package/dist_ts_tools/tool.filesystem.js +42 -1
  57. package/dist_ts_tools_node/classes.workspacereversion.d.ts +48 -0
  58. package/dist_ts_tools_node/classes.workspacereversion.js +202 -0
  59. package/dist_ts_tools_node/functions.fileimages.d.ts +22 -0
  60. package/dist_ts_tools_node/functions.fileimages.js +87 -0
  61. package/dist_ts_tools_node/index.d.ts +3 -1
  62. package/dist_ts_tools_node/index.js +2 -1
  63. package/dist_ts_tools_node/plugins.d.ts +1 -0
  64. package/dist_ts_tools_node/tool.context.d.ts +20 -0
  65. package/dist_ts_tools_node/tool.context.js +25 -4
  66. package/package.json +28 -4
  67. package/ts/00_commitinfo_data.ts +1 -1
  68. package/ts/errors.ts +10 -0
  69. package/ts/functions.toolpermissions.ts +60 -0
  70. package/ts/index.ts +6 -0
  71. package/ts/plugins.ts +1 -0
  72. package/ts_chat_harness/classes.chatclient.ts +343 -0
  73. package/ts_chat_harness/classes.chatrequesterror.ts +7 -0
  74. package/ts_chat_harness/classes.transcript.ts +1 -1
  75. package/ts_chat_harness/functions.intents.ts +2 -2
  76. package/ts_chat_harness/functions.mapping.ts +1 -1
  77. package/ts_chat_harness/index.ts +20 -0
  78. package/ts_chat_harness/interfaces.chat.ts +108 -0
  79. package/ts_chat_harness/readme.md +24 -0
  80. package/ts_chat_harness_server/classes.chathub.ts +400 -0
  81. package/ts_chat_harness_server/classes.chatsubscription.ts +257 -0
  82. package/ts_chat_harness_server/classes.sessionlists.ts +90 -0
  83. package/ts_chat_harness_server/classes.turngate.ts +247 -0
  84. package/ts_chat_harness_server/errors.ts +9 -0
  85. package/ts_chat_harness_server/functions.errorprojector.ts +40 -0
  86. package/ts_chat_harness_server/functions.validation.ts +104 -0
  87. package/ts_chat_harness_server/index.ts +19 -0
  88. package/ts_chat_harness_server/interfaces.ts +92 -0
  89. package/ts_chat_harness_server/plugins.ts +62 -0
  90. package/ts_chat_harness_server/readme.md +91 -0
  91. package/ts_chat_harness_typedsocket/classes.clienttransport.ts +87 -0
  92. package/ts_chat_harness_typedsocket/classes.serverbinding.ts +172 -0
  93. package/ts_chat_harness_typedsocket/index.ts +17 -0
  94. package/ts_chat_harness_typedsocket/interfaces.requests.ts +40 -0
  95. package/ts_chat_harness_typedsocket/plugins.ts +28 -0
  96. package/ts_chat_harness_typedsocket/readme.md +57 -0
  97. package/ts_tools/readme.md +10 -0
  98. package/ts_tools/tool.filesystem.ts +55 -0
  99. package/ts_tools_node/classes.workspacereversion.ts +255 -0
  100. package/ts_tools_node/functions.fileimages.ts +89 -0
  101. package/ts_tools_node/index.ts +7 -1
  102. package/ts_tools_node/plugins.ts +11 -0
  103. package/ts_tools_node/readme.md +27 -0
  104. 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
+ };
@@ -1,5 +1,11 @@
1
1
  export { createLocalToolExecutionContext } from './tool.context.js';
2
- export type { ILocalToolExecutionContextOptions } from './tool.context.js';
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';
@@ -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';
@@ -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: options.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: options.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: options.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);