@shanepadgett/tau-agent 0.26.0 → 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/docs/subagents.md +1 -1
- package/extensions/appshot/README.md +1 -1
- package/extensions/appshot/index.ts +5 -11
- package/extensions/context-pruning/README.md +2 -2
- package/extensions/context-pruning/index.ts +58 -57
- package/extensions/context-pruning/render.ts +51 -39
- package/extensions/context-pruning/settings.ts +4 -5
- package/extensions/explore/README.md +18 -11
- package/extensions/explore/ast-guidance.ts +67 -0
- package/extensions/explore/ast-languages.ts +61 -0
- package/extensions/explore/ast-tools.ts +1257 -91
- package/extensions/explore/ast-worker.ts +735 -66
- package/extensions/explore/index.ts +62 -6
- package/extensions/explore/orientation-state.ts +280 -0
- package/extensions/explore/read-stats-panel.ts +23 -1
- package/extensions/explore/read-stats.ts +21 -3
- package/extensions/explore/read.ts +58 -21
- package/extensions/explore/settings.ts +46 -0
- package/extensions/explore/traverse.ts +15 -4
- package/extensions/patch/executor.ts +95 -1
- package/extensions/patch/index.ts +1 -0
- package/extensions/soul/prompt.ts +1 -3
- package/extensions/subagent/agents/review.md +76 -25
- package/extensions/subagent/agents/scout.md +118 -22
- package/extensions/tau-help/help.md +3 -1
- package/native-bin/darwin-arm64/tau-ast +0 -0
- package/package.json +2 -3
- package/schemas/tau.schema.json +44 -4
- package/shared/bounded-text-result.ts +304 -0
- package/shared/events.ts +2 -1
- package/shared/temporary-output-store.ts +275 -0
package/schemas/tau.schema.json
CHANGED
|
@@ -61,12 +61,11 @@
|
|
|
61
61
|
"default": true,
|
|
62
62
|
"description": "Enable context pruning."
|
|
63
63
|
},
|
|
64
|
-
"
|
|
64
|
+
"nudgeEveryTokens": {
|
|
65
65
|
"type": "integer",
|
|
66
|
-
"default":
|
|
66
|
+
"default": 30000,
|
|
67
67
|
"minimum": 1,
|
|
68
|
-
"
|
|
69
|
-
"description": "Context growth interval between automatic pruning hints."
|
|
68
|
+
"description": "Active-context token interval between automatic pruning hints."
|
|
70
69
|
},
|
|
71
70
|
"nudgeInstructions": {
|
|
72
71
|
"type": "array",
|
|
@@ -87,6 +86,47 @@
|
|
|
87
86
|
},
|
|
88
87
|
"additionalProperties": false
|
|
89
88
|
},
|
|
89
|
+
"explore": {
|
|
90
|
+
"type": "object",
|
|
91
|
+
"required": [
|
|
92
|
+
"readGate"
|
|
93
|
+
],
|
|
94
|
+
"properties": {
|
|
95
|
+
"readGate": {
|
|
96
|
+
"type": "object",
|
|
97
|
+
"required": [
|
|
98
|
+
"includeGlobs",
|
|
99
|
+
"excludeGlobs"
|
|
100
|
+
],
|
|
101
|
+
"properties": {
|
|
102
|
+
"includeGlobs": {
|
|
103
|
+
"type": "array",
|
|
104
|
+
"items": {
|
|
105
|
+
"type": "string"
|
|
106
|
+
},
|
|
107
|
+
"default": [
|
|
108
|
+
"**/*"
|
|
109
|
+
],
|
|
110
|
+
"description": "Working-directory-relative supported source paths that require a structural attempt before read."
|
|
111
|
+
},
|
|
112
|
+
"excludeGlobs": {
|
|
113
|
+
"type": "array",
|
|
114
|
+
"items": {
|
|
115
|
+
"type": "string"
|
|
116
|
+
},
|
|
117
|
+
"default": [
|
|
118
|
+
"**/*.md",
|
|
119
|
+
"**/*.markdown",
|
|
120
|
+
"**/*.mdown"
|
|
121
|
+
],
|
|
122
|
+
"description": "Working-directory-relative paths excluded from the Explore read gate. Exclusions win."
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
"additionalProperties": false
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
"additionalProperties": false
|
|
129
|
+
},
|
|
90
130
|
"footer": {
|
|
91
131
|
"type": "object",
|
|
92
132
|
"properties": {
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MAX_BYTES,
|
|
3
|
+
DEFAULT_MAX_LINES,
|
|
4
|
+
formatSize,
|
|
5
|
+
truncateHead,
|
|
6
|
+
truncateTail,
|
|
7
|
+
} from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
TemporaryOutputError,
|
|
10
|
+
type TemporaryOutputStore,
|
|
11
|
+
type TemporaryOutputWriter,
|
|
12
|
+
} from "./temporary-output-store.ts";
|
|
13
|
+
|
|
14
|
+
export type BoundedTextStrategy = "head" | "tail" | "completeBlocks";
|
|
15
|
+
|
|
16
|
+
export interface BoundedTextOverflowDetails {
|
|
17
|
+
strategy: BoundedTextStrategy;
|
|
18
|
+
truncated: boolean;
|
|
19
|
+
shownLines: number;
|
|
20
|
+
totalLines: number;
|
|
21
|
+
shownBytes: number;
|
|
22
|
+
totalBytes: number;
|
|
23
|
+
fullOutputComplete: boolean;
|
|
24
|
+
temporaryPath: string | undefined;
|
|
25
|
+
failure: "quota" | "write" | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface BoundedTextResult {
|
|
29
|
+
content: string;
|
|
30
|
+
overflow: BoundedTextOverflowDetails;
|
|
31
|
+
visibleUnitIds: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function truncateBoundedHead(content: string): ReturnType<typeof truncateHead> {
|
|
35
|
+
return truncateHead(content, { maxBytes: DEFAULT_MAX_BYTES, maxLines: DEFAULT_MAX_LINES });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface VisibleBlock {
|
|
39
|
+
id: string | undefined;
|
|
40
|
+
label: string;
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class BoundedTextResultBuilder {
|
|
45
|
+
private readonly store: TemporaryOutputStore;
|
|
46
|
+
private readonly strategy: BoundedTextStrategy;
|
|
47
|
+
private snapshot = "";
|
|
48
|
+
private blocks: VisibleBlock[] = [];
|
|
49
|
+
private requiredBlocks: VisibleBlock[] = [];
|
|
50
|
+
private totalBytes = 0;
|
|
51
|
+
private newlineCount = 0;
|
|
52
|
+
private hasContent = false;
|
|
53
|
+
private endsWithNewline = false;
|
|
54
|
+
private overflowed = false;
|
|
55
|
+
private modelClosed = false;
|
|
56
|
+
private oversizedBlockLabel: string | undefined;
|
|
57
|
+
private output: TemporaryOutputWriter | undefined;
|
|
58
|
+
private persistenceFailure: "quota" | "write" | undefined;
|
|
59
|
+
private finished = false;
|
|
60
|
+
|
|
61
|
+
constructor(store: TemporaryOutputStore, strategy: BoundedTextStrategy) {
|
|
62
|
+
this.store = store;
|
|
63
|
+
this.strategy = strategy;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async append(text: string): Promise<void> {
|
|
67
|
+
if (this.strategy === "completeBlocks") throw new Error("Complete-block results require appendBlock");
|
|
68
|
+
this.requireOpen();
|
|
69
|
+
this.count(text);
|
|
70
|
+
if (!this.overflowed) {
|
|
71
|
+
const candidate = `${this.snapshot}${text}`;
|
|
72
|
+
const truncation = this.strategy === "head" ? truncateHead(candidate) : truncateTail(candidate);
|
|
73
|
+
this.snapshot = truncation.content;
|
|
74
|
+
if (!truncation.truncated) return;
|
|
75
|
+
this.overflowed = true;
|
|
76
|
+
await this.persist(candidate);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (this.strategy === "tail") this.snapshot = truncateTail(`${this.snapshot}${text}`).content;
|
|
80
|
+
await this.persist(text);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async appendBlock(id: string | undefined, label: string, text: string): Promise<void> {
|
|
84
|
+
if (this.strategy !== "completeBlocks") throw new Error("Text results require append");
|
|
85
|
+
this.requireOpen();
|
|
86
|
+
const prefix = this.hasContent ? "\n\n" : "";
|
|
87
|
+
const chunk = `${prefix}${text}`;
|
|
88
|
+
this.count(chunk);
|
|
89
|
+
|
|
90
|
+
if (!this.modelClosed) {
|
|
91
|
+
const candidate = `${this.snapshot}${chunk}`;
|
|
92
|
+
if (!truncateHead(candidate).truncated) {
|
|
93
|
+
this.snapshot = candidate;
|
|
94
|
+
this.blocks.push({ id, label, text });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.modelClosed = true;
|
|
98
|
+
this.overflowed = true;
|
|
99
|
+
if (this.blocks.length === 0) this.oversizedBlockLabel = label.replaceAll(/[\r\n]/g, " ");
|
|
100
|
+
await this.persist(candidate);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await this.persist(chunk);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async appendRequiredBlock(label: string, text: string): Promise<void> {
|
|
107
|
+
if (this.strategy !== "completeBlocks") throw new Error("Required blocks need complete-block retention");
|
|
108
|
+
this.requireOpen();
|
|
109
|
+
const block = { id: undefined, label, text };
|
|
110
|
+
this.requiredBlocks.push(block);
|
|
111
|
+
const prefix = this.hasContent ? "\n\n" : "";
|
|
112
|
+
const chunk = `${prefix}${text}`;
|
|
113
|
+
this.count(chunk);
|
|
114
|
+
if (!this.overflowed) {
|
|
115
|
+
const candidate = `${this.snapshot}${chunk}`;
|
|
116
|
+
if (!truncateHead(candidate).truncated) {
|
|
117
|
+
this.snapshot = candidate;
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.overflowed = true;
|
|
121
|
+
this.modelClosed = true;
|
|
122
|
+
await this.persist(candidate);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
await this.persist(chunk);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async finish(): Promise<BoundedTextResult> {
|
|
129
|
+
this.requireOpen();
|
|
130
|
+
this.finished = true;
|
|
131
|
+
let temporaryPath: string | undefined;
|
|
132
|
+
if (this.output) {
|
|
133
|
+
try {
|
|
134
|
+
temporaryPath = await this.output.finish();
|
|
135
|
+
} catch (error) {
|
|
136
|
+
this.persistenceFailure = error instanceof TemporaryOutputError ? error.code : "write";
|
|
137
|
+
}
|
|
138
|
+
this.output = undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const totalLines = lineCount(this.hasContent, this.newlineCount, this.endsWithNewline);
|
|
142
|
+
if (!this.overflowed) {
|
|
143
|
+
return {
|
|
144
|
+
content: this.snapshot,
|
|
145
|
+
overflow: {
|
|
146
|
+
strategy: this.strategy,
|
|
147
|
+
truncated: false,
|
|
148
|
+
shownLines: totalLines,
|
|
149
|
+
totalLines,
|
|
150
|
+
shownBytes: this.totalBytes,
|
|
151
|
+
totalBytes: this.totalBytes,
|
|
152
|
+
fullOutputComplete: true,
|
|
153
|
+
temporaryPath: undefined,
|
|
154
|
+
failure: undefined,
|
|
155
|
+
},
|
|
156
|
+
visibleUnitIds: this.blocks.flatMap((block) => (block.id === undefined ? [] : [block.id])),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (this.strategy === "completeBlocks") {
|
|
161
|
+
return this.finishBlocks(totalLines, temporaryPath);
|
|
162
|
+
}
|
|
163
|
+
return this.finishText(totalLines, temporaryPath);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async abort(): Promise<void> {
|
|
167
|
+
this.finished = true;
|
|
168
|
+
const output = this.output;
|
|
169
|
+
this.output = undefined;
|
|
170
|
+
if (output) await output.abort();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private finishBlocks(totalLines: number, temporaryPath: string | undefined): BoundedTextResult {
|
|
174
|
+
const blocks = [...this.blocks];
|
|
175
|
+
const required = this.requiredBlocks.map((block) => block.text).join("\n\n");
|
|
176
|
+
let retained = [...blocks.map((block) => block.text), required].filter(Boolean).join("\n\n");
|
|
177
|
+
let shownBytes = Buffer.byteLength(retained);
|
|
178
|
+
let shownLines = textLineCount(retained);
|
|
179
|
+
let notice = this.notice(shownLines, shownBytes, totalLines, temporaryPath);
|
|
180
|
+
const oversized = this.oversizedBlockLabel
|
|
181
|
+
? `[File block omitted because it exceeds the model-output budget: ${this.oversizedBlockLabel}. Run a non-recursive outline for that file.]`
|
|
182
|
+
: "";
|
|
183
|
+
while (blocks.length > 0 && truncateHead([retained, oversized, notice].filter(Boolean).join("\n\n")).truncated) {
|
|
184
|
+
blocks.pop();
|
|
185
|
+
retained = [...blocks.map((block) => block.text), required].filter(Boolean).join("\n\n");
|
|
186
|
+
shownBytes = Buffer.byteLength(retained);
|
|
187
|
+
shownLines = textLineCount(retained);
|
|
188
|
+
notice = this.notice(shownLines, shownBytes, totalLines, temporaryPath);
|
|
189
|
+
}
|
|
190
|
+
let content = [retained, oversized, notice].filter(Boolean).join("\n\n");
|
|
191
|
+
if (truncateHead(content).truncated) content = truncateHead(content).content;
|
|
192
|
+
return {
|
|
193
|
+
content,
|
|
194
|
+
overflow: this.overflowDetails(shownLines, shownBytes, totalLines, temporaryPath),
|
|
195
|
+
visibleUnitIds: blocks.flatMap((block) => (block.id === undefined ? [] : [block.id])),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private finishText(totalLines: number, temporaryPath: string | undefined): BoundedTextResult {
|
|
200
|
+
let retained = this.snapshot;
|
|
201
|
+
let shownBytes = Buffer.byteLength(retained);
|
|
202
|
+
let shownLines = textLineCount(retained);
|
|
203
|
+
let notice = this.notice(shownLines, shownBytes, totalLines, temporaryPath);
|
|
204
|
+
for (let iteration = 0; iteration < 3; iteration += 1) {
|
|
205
|
+
const noticeLines = textLineCount(notice) + 1;
|
|
206
|
+
const noticeBytes = Buffer.byteLength(notice) + 2;
|
|
207
|
+
const options = {
|
|
208
|
+
maxLines: Math.max(0, DEFAULT_MAX_LINES - noticeLines),
|
|
209
|
+
maxBytes: Math.max(0, DEFAULT_MAX_BYTES - noticeBytes),
|
|
210
|
+
};
|
|
211
|
+
retained = (
|
|
212
|
+
this.strategy === "head" ? truncateHead(this.snapshot, options) : truncateTail(this.snapshot, options)
|
|
213
|
+
).content;
|
|
214
|
+
shownBytes = Buffer.byteLength(retained);
|
|
215
|
+
shownLines = textLineCount(retained);
|
|
216
|
+
notice = this.notice(shownLines, shownBytes, totalLines, temporaryPath);
|
|
217
|
+
}
|
|
218
|
+
return {
|
|
219
|
+
content: [retained, notice].filter(Boolean).join("\n\n"),
|
|
220
|
+
overflow: this.overflowDetails(shownLines, shownBytes, totalLines, temporaryPath),
|
|
221
|
+
visibleUnitIds: [],
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
private overflowDetails(
|
|
226
|
+
shownLines: number,
|
|
227
|
+
shownBytes: number,
|
|
228
|
+
totalLines: number,
|
|
229
|
+
temporaryPath: string | undefined,
|
|
230
|
+
): BoundedTextOverflowDetails {
|
|
231
|
+
return {
|
|
232
|
+
strategy: this.strategy,
|
|
233
|
+
truncated: true,
|
|
234
|
+
shownLines,
|
|
235
|
+
totalLines,
|
|
236
|
+
shownBytes,
|
|
237
|
+
totalBytes: this.totalBytes,
|
|
238
|
+
fullOutputComplete: temporaryPath !== undefined && this.persistenceFailure === undefined,
|
|
239
|
+
temporaryPath,
|
|
240
|
+
failure: this.persistenceFailure,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private notice(
|
|
245
|
+
shownLines: number,
|
|
246
|
+
shownBytes: number,
|
|
247
|
+
totalLines: number,
|
|
248
|
+
temporaryPath: string | undefined,
|
|
249
|
+
): string {
|
|
250
|
+
return formatBoundedTextOverflow({
|
|
251
|
+
strategy: this.strategy,
|
|
252
|
+
truncated: true,
|
|
253
|
+
shownLines,
|
|
254
|
+
totalLines,
|
|
255
|
+
shownBytes,
|
|
256
|
+
totalBytes: this.totalBytes,
|
|
257
|
+
fullOutputComplete: temporaryPath !== undefined && this.persistenceFailure === undefined,
|
|
258
|
+
temporaryPath,
|
|
259
|
+
failure: this.persistenceFailure,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private count(text: string): void {
|
|
264
|
+
if (text.length === 0) return;
|
|
265
|
+
this.totalBytes += Buffer.byteLength(text);
|
|
266
|
+
this.newlineCount += text.split("\n").length - 1;
|
|
267
|
+
this.hasContent = true;
|
|
268
|
+
this.endsWithNewline = text.endsWith("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private async persist(text: string): Promise<void> {
|
|
272
|
+
if (this.persistenceFailure) return;
|
|
273
|
+
try {
|
|
274
|
+
this.output ??= await this.store.createOutput();
|
|
275
|
+
await this.output.write(text);
|
|
276
|
+
} catch (error) {
|
|
277
|
+
this.persistenceFailure = error instanceof TemporaryOutputError ? error.code : "write";
|
|
278
|
+
const output = this.output;
|
|
279
|
+
this.output = undefined;
|
|
280
|
+
if (output) await output.abort();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private requireOpen(): void {
|
|
285
|
+
if (this.finished) throw new Error("Bounded text result is already finished");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function formatBoundedTextOverflow(details: BoundedTextOverflowDetails): string {
|
|
290
|
+
const sizes = `${details.shownLines} of ${details.totalLines} lines (${formatSize(details.shownBytes)} of ${formatSize(details.totalBytes)})`;
|
|
291
|
+
if (details.fullOutputComplete && details.temporaryPath) {
|
|
292
|
+
return `[Output truncated: showing ${sizes}. Complete output: ${details.temporaryPath}\nTemporary output is valid only for the active session. Use targeted grep or ranged read; reading the whole file will hit the same output limits.]`;
|
|
293
|
+
}
|
|
294
|
+
const reason = details.failure === "quota" ? "disk quota exceeded" : "temporary-file write failed";
|
|
295
|
+
return `[Output truncated: showing ${sizes}. Complete temporary output unavailable: ${reason}.]`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function textLineCount(text: string): number {
|
|
299
|
+
return lineCount(text.length > 0, text.split("\n").length - 1, text.endsWith("\n"));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function lineCount(hasContent: boolean, newlineCount: number, endsWithNewline: boolean): number {
|
|
303
|
+
return hasContent ? newlineCount + (endsWithNewline ? 0 : 1) : 0;
|
|
304
|
+
}
|
package/shared/events.ts
CHANGED
|
@@ -15,7 +15,7 @@ export type TauAgentEvents = {
|
|
|
15
15
|
disposition: "notify" | "discard";
|
|
16
16
|
};
|
|
17
17
|
"tau:file-mutation.applied": {
|
|
18
|
-
source: "patch";
|
|
18
|
+
source: "patch" | "locatorEdit";
|
|
19
19
|
toolCallId: string;
|
|
20
20
|
cwd: string;
|
|
21
21
|
status: "completed" | "partial" | "failed";
|
|
@@ -25,6 +25,7 @@ export type TauAgentEvents = {
|
|
|
25
25
|
move?: { from: string; to: string };
|
|
26
26
|
linesAdded: number;
|
|
27
27
|
linesRemoved: number;
|
|
28
|
+
resultingFingerprint: string | null;
|
|
28
29
|
snapshotRanges?: Array<{ startLine: number; endLine: number }>;
|
|
29
30
|
}>;
|
|
30
31
|
};
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { mkdir, open, opendir, readFile, rename, rm, stat } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const DIRECTORY_PREFIX = "tau-tool-output-v1-";
|
|
8
|
+
const OWNER_FILE = ".tau-owner.json";
|
|
9
|
+
|
|
10
|
+
const TEMPORARY_OUTPUT_FILE_QUOTA_BYTES = 64 * 1024 * 1024;
|
|
11
|
+
const TEMPORARY_OUTPUT_SESSION_QUOTA_BYTES = 256 * 1024 * 1024;
|
|
12
|
+
const TEMPORARY_OUTPUT_ORPHAN_LIFETIME_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
export function createTemporaryOutputStore(): TemporaryOutputStore {
|
|
15
|
+
return new TemporaryOutputStore(
|
|
16
|
+
tmpdir(),
|
|
17
|
+
TEMPORARY_OUTPUT_FILE_QUOTA_BYTES,
|
|
18
|
+
TEMPORARY_OUTPUT_SESSION_QUOTA_BYTES,
|
|
19
|
+
TEMPORARY_OUTPUT_ORPHAN_LIFETIME_MS,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface OwnerMarker {
|
|
24
|
+
kind: "tau-tool-output";
|
|
25
|
+
version: 1;
|
|
26
|
+
pid: number;
|
|
27
|
+
createdAt: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class TemporaryOutputError extends Error {
|
|
31
|
+
readonly code: "quota" | "write";
|
|
32
|
+
|
|
33
|
+
constructor(code: "quota" | "write", message: string) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.name = "TemporaryOutputError";
|
|
36
|
+
this.code = code;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TemporaryOutputStore {
|
|
41
|
+
private readonly baseDirectory: string;
|
|
42
|
+
private readonly fileQuotaBytes: number;
|
|
43
|
+
private readonly sessionQuotaBytes: number;
|
|
44
|
+
private readonly orphanLifetimeMs: number;
|
|
45
|
+
private started = false;
|
|
46
|
+
private startPromise: Promise<void> | undefined;
|
|
47
|
+
private directory: string | undefined;
|
|
48
|
+
private directoryPromise: Promise<string> | undefined;
|
|
49
|
+
private sessionBytes = 0;
|
|
50
|
+
private readonly active = new Set<TemporaryOutputWriter>();
|
|
51
|
+
private readonly creating = new Set<Promise<TemporaryOutputWriter>>();
|
|
52
|
+
private shuttingDown = false;
|
|
53
|
+
private shutdownPromise: Promise<void> | undefined;
|
|
54
|
+
|
|
55
|
+
constructor(baseDirectory: string, fileQuotaBytes: number, sessionQuotaBytes: number, orphanLifetimeMs: number) {
|
|
56
|
+
this.baseDirectory = baseDirectory;
|
|
57
|
+
this.fileQuotaBytes = fileQuotaBytes;
|
|
58
|
+
this.sessionQuotaBytes = sessionQuotaBytes;
|
|
59
|
+
this.orphanLifetimeMs = orphanLifetimeMs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async start(): Promise<void> {
|
|
63
|
+
if (this.started) return;
|
|
64
|
+
this.startPromise ??= this.removeStaleOrphans().then(() => {
|
|
65
|
+
this.started = true;
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
await this.startPromise;
|
|
69
|
+
} finally {
|
|
70
|
+
this.startPromise = undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async createOutput(): Promise<TemporaryOutputWriter> {
|
|
75
|
+
if (this.shuttingDown) throw new TemporaryOutputError("write", "Temporary output session is shutting down");
|
|
76
|
+
const creation = this.createOutputFile();
|
|
77
|
+
this.creating.add(creation);
|
|
78
|
+
try {
|
|
79
|
+
return await creation;
|
|
80
|
+
} finally {
|
|
81
|
+
this.creating.delete(creation);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async createOutputFile(): Promise<TemporaryOutputWriter> {
|
|
86
|
+
await this.start();
|
|
87
|
+
if (this.shuttingDown) throw new TemporaryOutputError("write", "Temporary output session is shutting down");
|
|
88
|
+
this.directoryPromise ??= (async () => {
|
|
89
|
+
const directory = join(this.baseDirectory, `${DIRECTORY_PREFIX}${randomUUID()}`);
|
|
90
|
+
try {
|
|
91
|
+
await mkdir(directory, { mode: 0o700 });
|
|
92
|
+
const marker = {
|
|
93
|
+
kind: "tau-tool-output",
|
|
94
|
+
version: 1,
|
|
95
|
+
pid: process.pid,
|
|
96
|
+
createdAt: Date.now(),
|
|
97
|
+
} satisfies OwnerMarker;
|
|
98
|
+
const markerFile = await open(
|
|
99
|
+
join(directory, OWNER_FILE),
|
|
100
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
|
|
101
|
+
0o600,
|
|
102
|
+
);
|
|
103
|
+
try {
|
|
104
|
+
await markerFile.writeFile(JSON.stringify(marker));
|
|
105
|
+
} finally {
|
|
106
|
+
await markerFile.close();
|
|
107
|
+
}
|
|
108
|
+
return directory;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
await rm(directory, { recursive: true, force: true });
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
const directory = await this.directoryPromise;
|
|
115
|
+
this.directory = directory;
|
|
116
|
+
|
|
117
|
+
const partialPath = join(directory, `${randomUUID()}.partial`);
|
|
118
|
+
let file;
|
|
119
|
+
try {
|
|
120
|
+
file = await open(partialPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
throw new TemporaryOutputError("write", `Cannot create temporary output: ${String(error)}`);
|
|
123
|
+
}
|
|
124
|
+
const writer = new TemporaryOutputWriter(this, file, partialPath, partialPath.replace(/\.partial$/, ".txt"));
|
|
125
|
+
this.active.add(writer);
|
|
126
|
+
return writer;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async shutdown(): Promise<void> {
|
|
130
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
131
|
+
this.shutdownPromise = this.performShutdown();
|
|
132
|
+
try {
|
|
133
|
+
await this.shutdownPromise;
|
|
134
|
+
} finally {
|
|
135
|
+
this.shutdownPromise = undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async performShutdown(): Promise<void> {
|
|
140
|
+
this.shuttingDown = true;
|
|
141
|
+
await Promise.allSettled(this.creating);
|
|
142
|
+
for (const writer of this.active) await writer.abort();
|
|
143
|
+
const directory = this.directory;
|
|
144
|
+
this.directory = undefined;
|
|
145
|
+
this.sessionBytes = 0;
|
|
146
|
+
this.started = false;
|
|
147
|
+
this.startPromise = undefined;
|
|
148
|
+
this.directoryPromise = undefined;
|
|
149
|
+
if (directory) await rm(directory, { recursive: true, force: true });
|
|
150
|
+
this.shuttingDown = false;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
reserve(writer: TemporaryOutputWriter, bytes: number): void {
|
|
154
|
+
if (writer.byteLength + bytes > this.fileQuotaBytes) {
|
|
155
|
+
throw new TemporaryOutputError(
|
|
156
|
+
"quota",
|
|
157
|
+
`Temporary output exceeds the ${this.fileQuotaBytes}-byte per-file quota`,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (this.sessionBytes + bytes > this.sessionQuotaBytes) {
|
|
161
|
+
throw new TemporaryOutputError(
|
|
162
|
+
"quota",
|
|
163
|
+
`Temporary output exceeds the ${this.sessionQuotaBytes}-byte per-session quota`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
writer.byteLength += bytes;
|
|
167
|
+
this.sessionBytes += bytes;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
release(writer: TemporaryOutputWriter): void {
|
|
171
|
+
this.sessionBytes = Math.max(0, this.sessionBytes - writer.byteLength);
|
|
172
|
+
writer.byteLength = 0;
|
|
173
|
+
this.active.delete(writer);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
complete(writer: TemporaryOutputWriter): void {
|
|
177
|
+
this.active.delete(writer);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private async removeStaleOrphans(): Promise<void> {
|
|
181
|
+
let directory;
|
|
182
|
+
try {
|
|
183
|
+
directory = await opendir(this.baseDirectory);
|
|
184
|
+
} catch {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
for await (const entry of directory) {
|
|
188
|
+
if (!entry.isDirectory() || !entry.name.startsWith(DIRECTORY_PREFIX)) continue;
|
|
189
|
+
const path = join(this.baseDirectory, entry.name);
|
|
190
|
+
try {
|
|
191
|
+
const metadata = await stat(path);
|
|
192
|
+
if (Date.now() - metadata.mtimeMs < this.orphanLifetimeMs) continue;
|
|
193
|
+
const marker = JSON.parse(await readFile(join(path, OWNER_FILE), "utf8")) as Partial<OwnerMarker>;
|
|
194
|
+
if (marker.kind !== "tau-tool-output" || marker.version !== 1 || typeof marker.pid !== "number") continue;
|
|
195
|
+
try {
|
|
196
|
+
process.kill(marker.pid, 0);
|
|
197
|
+
continue;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (!isErrorCode(error, "ESRCH")) continue;
|
|
200
|
+
}
|
|
201
|
+
await rm(path, { recursive: true, force: true });
|
|
202
|
+
} catch {
|
|
203
|
+
// An untrusted or concurrently changing directory is not ours to remove.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class TemporaryOutputWriter {
|
|
210
|
+
private readonly store: TemporaryOutputStore;
|
|
211
|
+
private readonly file: Awaited<ReturnType<typeof open>>;
|
|
212
|
+
private readonly partialPath: string;
|
|
213
|
+
private readonly completePath: string;
|
|
214
|
+
byteLength = 0;
|
|
215
|
+
private closed = false;
|
|
216
|
+
|
|
217
|
+
constructor(
|
|
218
|
+
store: TemporaryOutputStore,
|
|
219
|
+
file: Awaited<ReturnType<typeof open>>,
|
|
220
|
+
partialPath: string,
|
|
221
|
+
completePath: string,
|
|
222
|
+
) {
|
|
223
|
+
this.store = store;
|
|
224
|
+
this.file = file;
|
|
225
|
+
this.partialPath = partialPath;
|
|
226
|
+
this.completePath = completePath;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async write(text: string): Promise<void> {
|
|
230
|
+
if (this.closed) throw new TemporaryOutputError("write", "Temporary output is already closed");
|
|
231
|
+
const buffer = Buffer.from(text);
|
|
232
|
+
try {
|
|
233
|
+
this.store.reserve(this, buffer.byteLength);
|
|
234
|
+
let offset = 0;
|
|
235
|
+
while (offset < buffer.byteLength) {
|
|
236
|
+
const result = await this.file.write(buffer, offset, buffer.byteLength - offset);
|
|
237
|
+
if (result.bytesWritten === 0) throw new Error("write returned zero bytes");
|
|
238
|
+
offset += result.bytesWritten;
|
|
239
|
+
}
|
|
240
|
+
} catch (error) {
|
|
241
|
+
await this.abort();
|
|
242
|
+
if (error instanceof TemporaryOutputError) throw error;
|
|
243
|
+
throw new TemporaryOutputError("write", `Cannot write temporary output: ${String(error)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async finish(): Promise<string> {
|
|
248
|
+
if (this.closed) throw new TemporaryOutputError("write", "Temporary output is already closed");
|
|
249
|
+
this.closed = true;
|
|
250
|
+
try {
|
|
251
|
+
await this.file.close();
|
|
252
|
+
await rename(this.partialPath, this.completePath);
|
|
253
|
+
this.store.complete(this);
|
|
254
|
+
return this.completePath;
|
|
255
|
+
} catch (error) {
|
|
256
|
+
this.closed = false;
|
|
257
|
+
await this.abort();
|
|
258
|
+
throw new TemporaryOutputError("write", `Cannot complete temporary output: ${String(error)}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async abort(): Promise<void> {
|
|
263
|
+
if (!this.closed) {
|
|
264
|
+
this.closed = true;
|
|
265
|
+
await this.file.close().catch(() => {});
|
|
266
|
+
}
|
|
267
|
+
await rm(this.partialPath, { force: true }).catch(() => {});
|
|
268
|
+
await rm(this.completePath, { force: true }).catch(() => {});
|
|
269
|
+
this.store.release(this);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isErrorCode(error: unknown, code: string): boolean {
|
|
274
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
275
|
+
}
|