pi-ultracode 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -42
- package/examples/workflows/loop-until-dry-bugs.workflow.js +16 -6
- package/extensions/ultracode.ts +0 -1
- package/package.json +7 -7
- package/src/commands.ts +19 -23
- package/src/index.ts +1 -1
- package/src/mode.ts +6 -40
- package/src/prompts.ts +18 -28
- package/src/workflow/admission.ts +318 -0
- package/src/workflow/agent-runner.ts +12 -0
- package/src/workflow/display.ts +17 -13
- package/src/workflow/journal.ts +0 -0
- package/src/workflow/json-schema.ts +39 -17
- package/src/workflow/leases.ts +60 -0
- package/src/workflow/parser.ts +809 -5
- package/src/workflow/registry.ts +39 -22
- package/src/workflow/run-artifacts.ts +231 -0
- package/src/workflow/run-details.ts +106 -28
- package/src/workflow/runtime.ts +640 -303
- package/src/workflow/script-executor.ts +317 -0
- package/src/workflow/script-worker.mjs +678 -0
- package/src/workflow/structured-output.ts +2 -0
- package/src/workflow/tool.ts +207 -74
- package/src/workflow/value-limits.ts +363 -0
- package/src/workflow/worktree.ts +5 -3
- package/types/workflow.d.ts +60 -22
package/src/workflow/registry.ts
CHANGED
|
@@ -16,10 +16,14 @@ export interface RunHandle {
|
|
|
16
16
|
details?: WorkflowRunDetails;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
interface RegistryScope {
|
|
20
|
+
runs: Map<string, RunHandle>;
|
|
21
|
+
order: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
export class WorkflowRegistry {
|
|
20
|
-
private readonly
|
|
25
|
+
private readonly scopes = new Map<string, RegistryScope>();
|
|
21
26
|
private readonly listeners = new Set<() => void>();
|
|
22
|
-
private order: string[] = [];
|
|
23
27
|
private scopeDir?: string;
|
|
24
28
|
|
|
25
29
|
register(
|
|
@@ -28,12 +32,13 @@ export class WorkflowRegistry {
|
|
|
28
32
|
abort: () => void,
|
|
29
33
|
details?: WorkflowRunDetails,
|
|
30
34
|
): RunHandle {
|
|
35
|
+
const scope = this.currentScope();
|
|
31
36
|
const handle: RunHandle = { snapshot, abort, startedAt: Date.now(), details };
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
// Keep at most the 50 most recent runs
|
|
36
|
-
this.trimRuns();
|
|
37
|
+
scope.runs.set(runId, handle);
|
|
38
|
+
scope.order = scope.order.filter((id) => id !== runId);
|
|
39
|
+
scope.order.push(runId);
|
|
40
|
+
// Keep at most the 50 most recent runs per session without evicting active work.
|
|
41
|
+
this.trimRuns(scope);
|
|
37
42
|
this.notify();
|
|
38
43
|
return handle;
|
|
39
44
|
}
|
|
@@ -56,6 +61,7 @@ export class WorkflowRegistry {
|
|
|
56
61
|
/** Restore the newest task-detail manifests for the current Pi session. */
|
|
57
62
|
restoreRuns(runsDir: string): number {
|
|
58
63
|
this.setScope(runsDir);
|
|
64
|
+
const scope = this.currentScope();
|
|
59
65
|
let files: string[];
|
|
60
66
|
try {
|
|
61
67
|
files = fs.readdirSync(runsDir)
|
|
@@ -69,7 +75,7 @@ export class WorkflowRegistry {
|
|
|
69
75
|
let restored = 0;
|
|
70
76
|
for (const file of files) {
|
|
71
77
|
const loaded = WorkflowRunDetails.restore(file);
|
|
72
|
-
if (!loaded ||
|
|
78
|
+
if (!loaded || scope.runs.has(loaded.snapshot.runId ?? loaded.details.runId)) continue;
|
|
73
79
|
const runId = loaded.snapshot.runId ?? loaded.details.runId;
|
|
74
80
|
if (loaded.snapshot.status === "running") {
|
|
75
81
|
loaded.snapshot.status = "aborted";
|
|
@@ -94,31 +100,32 @@ export class WorkflowRegistry {
|
|
|
94
100
|
startedAt,
|
|
95
101
|
details: loaded.details,
|
|
96
102
|
};
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
scope.runs.set(runId, handle);
|
|
104
|
+
scope.order.push(runId);
|
|
99
105
|
restored++;
|
|
100
106
|
}
|
|
101
|
-
this.trimRuns();
|
|
107
|
+
this.trimRuns(scope);
|
|
102
108
|
if (restored) this.notify();
|
|
103
109
|
return restored;
|
|
104
110
|
}
|
|
105
111
|
|
|
112
|
+
/** Select a session without discarding live handles from other sessions. */
|
|
106
113
|
setScope(runsDir: string): void {
|
|
107
114
|
const resolved = path.resolve(runsDir);
|
|
108
115
|
if (this.scopeDir === resolved) return;
|
|
109
116
|
this.scopeDir = resolved;
|
|
110
|
-
this.
|
|
111
|
-
this.order = [];
|
|
117
|
+
this.currentScope();
|
|
112
118
|
this.notify();
|
|
113
119
|
}
|
|
114
120
|
|
|
115
121
|
get(runId: string): RunHandle | undefined {
|
|
116
|
-
return this.runs.get(runId);
|
|
122
|
+
return this.currentScope().runs.get(runId);
|
|
117
123
|
}
|
|
118
124
|
|
|
119
125
|
list(): RunHandle[] {
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
const scope = this.currentScope();
|
|
127
|
+
return scope.order
|
|
128
|
+
.map((id) => scope.runs.get(id))
|
|
122
129
|
.filter((handle): handle is RunHandle => Boolean(handle))
|
|
123
130
|
.sort((left, right) => right.startedAt - left.startedAt);
|
|
124
131
|
}
|
|
@@ -128,7 +135,7 @@ export class WorkflowRegistry {
|
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
abortAll(): void {
|
|
131
|
-
for (const handle of this.runs.values()) {
|
|
138
|
+
for (const handle of this.currentScope().runs.values()) {
|
|
132
139
|
if (handle.snapshot.status === "running") {
|
|
133
140
|
try {
|
|
134
141
|
handle.abort();
|
|
@@ -139,16 +146,26 @@ export class WorkflowRegistry {
|
|
|
139
146
|
}
|
|
140
147
|
}
|
|
141
148
|
|
|
142
|
-
private
|
|
143
|
-
|
|
144
|
-
|
|
149
|
+
private currentScope(): RegistryScope {
|
|
150
|
+
const key = this.scopeDir ?? "<default>";
|
|
151
|
+
let scope = this.scopes.get(key);
|
|
152
|
+
if (!scope) {
|
|
153
|
+
scope = { runs: new Map(), order: [] };
|
|
154
|
+
this.scopes.set(key, scope);
|
|
155
|
+
}
|
|
156
|
+
return scope;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private trimRuns(scope: RegistryScope): void {
|
|
160
|
+
while (scope.runs.size > 50) {
|
|
161
|
+
const candidate = [...scope.runs.entries()].sort((left, right) => {
|
|
145
162
|
const leftActive = left[1].snapshot.status === "running" ? 1 : 0;
|
|
146
163
|
const rightActive = right[1].snapshot.status === "running" ? 1 : 0;
|
|
147
164
|
return leftActive - rightActive || left[1].startedAt - right[1].startedAt;
|
|
148
165
|
})[0];
|
|
149
166
|
if (!candidate) break;
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
scope.runs.delete(candidate[0]);
|
|
168
|
+
scope.order = scope.order.filter((id) => id !== candidate[0]);
|
|
152
169
|
}
|
|
153
170
|
}
|
|
154
171
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
|
|
6
|
+
const NO_FOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
7
|
+
|
|
8
|
+
export function standaloneWorkflowRunsDir(cwd: string): string {
|
|
9
|
+
const cwdKey = crypto.createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 24);
|
|
10
|
+
return path.join(os.homedir(), ".pi", "ultracode-runs", cwdKey);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Ensure an artifact directory is real; with a root, reject every symlink below that root. */
|
|
14
|
+
export function ensurePrivateArtifactDirectory(dir: string, trustedRoot?: string): void {
|
|
15
|
+
if (trustedRoot !== undefined) {
|
|
16
|
+
ensureContainedDirectory(trustedRoot, dir);
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
let created = false;
|
|
20
|
+
try {
|
|
21
|
+
fs.lstatSync(dir);
|
|
22
|
+
} catch (error: any) {
|
|
23
|
+
if (error?.code !== "ENOENT") throw error;
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
created = true;
|
|
26
|
+
}
|
|
27
|
+
assertRealDirectory(dir);
|
|
28
|
+
if (process.platform !== "win32") fs.chmodSync(dir, 0o700);
|
|
29
|
+
if (created) {
|
|
30
|
+
fsyncArtifactDirectory(dir);
|
|
31
|
+
const parent = path.dirname(dir);
|
|
32
|
+
if (parent !== dir) fsyncArtifactDirectory(parent);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ensureContainedDirectory(trustedRoot: string, dir: string): void {
|
|
37
|
+
const root = path.resolve(trustedRoot);
|
|
38
|
+
const target = path.resolve(dir);
|
|
39
|
+
assertRealDirectory(root);
|
|
40
|
+
const relative = path.relative(root, target);
|
|
41
|
+
if (relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
42
|
+
throw new Error(`workflow artifact directory must stay inside ${root}`);
|
|
43
|
+
}
|
|
44
|
+
let current = root;
|
|
45
|
+
for (const part of relative ? relative.split(path.sep) : []) {
|
|
46
|
+
current = path.join(current, part);
|
|
47
|
+
let created = false;
|
|
48
|
+
try {
|
|
49
|
+
assertRealDirectory(current);
|
|
50
|
+
} catch (error: any) {
|
|
51
|
+
if (error?.code !== "ENOENT") throw error;
|
|
52
|
+
fs.mkdirSync(current, { mode: 0o700 });
|
|
53
|
+
created = true;
|
|
54
|
+
assertRealDirectory(current);
|
|
55
|
+
}
|
|
56
|
+
if (process.platform !== "win32") fs.chmodSync(current, 0o700);
|
|
57
|
+
if (created) {
|
|
58
|
+
fsyncArtifactDirectory(current);
|
|
59
|
+
fsyncArtifactDirectory(path.dirname(current));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertRealDirectory(dir: string): void {
|
|
65
|
+
const stat = fs.lstatSync(dir);
|
|
66
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
67
|
+
throw new Error(`workflow artifact directory must be a real directory: ${dir}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Persist directory-entry changes on platforms that support directory fsync. */
|
|
72
|
+
export function fsyncArtifactDirectory(dir: string): void {
|
|
73
|
+
if (process.platform === "win32") return;
|
|
74
|
+
const fd = fs.openSync(dir, fs.constants.O_RDONLY | (fs.constants.O_DIRECTORY ?? 0));
|
|
75
|
+
try {
|
|
76
|
+
const stat = fs.fstatSync(fd);
|
|
77
|
+
if (!stat.isDirectory()) throw new Error(`workflow artifact parent must be a directory: ${dir}`);
|
|
78
|
+
fs.fsyncSync(fd);
|
|
79
|
+
} finally {
|
|
80
|
+
fs.closeSync(fd);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function artifactPathExists(filePath: string): boolean {
|
|
85
|
+
try {
|
|
86
|
+
fs.lstatSync(filePath);
|
|
87
|
+
return true;
|
|
88
|
+
} catch (error: any) {
|
|
89
|
+
if (error?.code === "ENOENT") return false;
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Reject symlinks and non-regular artifact files before reading or resuming them. */
|
|
95
|
+
export function assertRegularArtifactFile(filePath: string, label: string): fs.Stats {
|
|
96
|
+
let stat: fs.Stats;
|
|
97
|
+
try {
|
|
98
|
+
stat = fs.lstatSync(filePath);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new Error(`${label} was not found: ${filePath}`, { cause: error });
|
|
101
|
+
}
|
|
102
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
103
|
+
throw new Error(`${label} must be a regular file and may not be a symlink: ${filePath}`);
|
|
104
|
+
}
|
|
105
|
+
return stat;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readContainedArtifactFile(
|
|
109
|
+
trustedRoot: string,
|
|
110
|
+
filePath: string,
|
|
111
|
+
label: string,
|
|
112
|
+
maxBytes = 16 * 1024 * 1024,
|
|
113
|
+
): string {
|
|
114
|
+
const root = path.resolve(trustedRoot);
|
|
115
|
+
const target = path.resolve(filePath);
|
|
116
|
+
const relative = path.relative(root, target);
|
|
117
|
+
if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
|
|
118
|
+
throw new Error(`${label} must stay inside ${root}`);
|
|
119
|
+
}
|
|
120
|
+
let current = root;
|
|
121
|
+
const parts = relative.split(path.sep);
|
|
122
|
+
for (const [index, part] of parts.entries()) {
|
|
123
|
+
current = path.join(current, part);
|
|
124
|
+
const stat = fs.lstatSync(current);
|
|
125
|
+
if (stat.isSymbolicLink()) throw new Error(`${label} path may not contain symlinks: ${current}`);
|
|
126
|
+
if (index < parts.length - 1 && !stat.isDirectory()) {
|
|
127
|
+
throw new Error(`${label} parent must be a directory: ${current}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return readArtifactFile(target, label, maxBytes);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function readArtifactFile(filePath: string, label: string, maxBytes = 16 * 1024 * 1024): string {
|
|
134
|
+
assertRegularArtifactFile(filePath, label);
|
|
135
|
+
const fd = fs.openSync(filePath, fs.constants.O_RDONLY | NO_FOLLOW);
|
|
136
|
+
try {
|
|
137
|
+
const stat = fs.fstatSync(fd);
|
|
138
|
+
if (!stat.isFile()) throw new Error(`${label} must be a regular file: ${filePath}`);
|
|
139
|
+
if (stat.size > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes: ${filePath}`);
|
|
140
|
+
const chunks: Buffer[] = [];
|
|
141
|
+
let total = 0;
|
|
142
|
+
while (total <= maxBytes) {
|
|
143
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, maxBytes + 1 - total));
|
|
144
|
+
const bytesRead = fs.readSync(fd, chunk, 0, chunk.length, null);
|
|
145
|
+
if (bytesRead === 0) break;
|
|
146
|
+
chunks.push(chunk.subarray(0, bytesRead));
|
|
147
|
+
total += bytesRead;
|
|
148
|
+
}
|
|
149
|
+
if (total > maxBytes) throw new Error(`${label} exceeds ${maxBytes} bytes: ${filePath}`);
|
|
150
|
+
return Buffer.concat(chunks, total).toString("utf8");
|
|
151
|
+
} finally {
|
|
152
|
+
fs.closeSync(fd);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function appendArtifactFile(
|
|
157
|
+
filePath: string,
|
|
158
|
+
data: string | Uint8Array,
|
|
159
|
+
options: { trustedRoot: string },
|
|
160
|
+
): void {
|
|
161
|
+
ensurePrivateArtifactDirectory(path.dirname(filePath), options.trustedRoot);
|
|
162
|
+
let fd: number | undefined;
|
|
163
|
+
try {
|
|
164
|
+
fd = fs.openSync(
|
|
165
|
+
filePath,
|
|
166
|
+
fs.constants.O_CREAT | fs.constants.O_WRONLY | fs.constants.O_APPEND | NO_FOLLOW,
|
|
167
|
+
0o600,
|
|
168
|
+
);
|
|
169
|
+
const stat = fs.fstatSync(fd);
|
|
170
|
+
if (!stat.isFile()) throw new Error(`workflow artifact must be a regular file: ${filePath}`);
|
|
171
|
+
fs.writeFileSync(fd, data);
|
|
172
|
+
} finally {
|
|
173
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Write an artifact through an exclusive random temporary file and atomic rename.
|
|
179
|
+
* Existing symlinks are rejected rather than followed.
|
|
180
|
+
*/
|
|
181
|
+
export function writeArtifactFile(
|
|
182
|
+
filePath: string,
|
|
183
|
+
data: string | Uint8Array,
|
|
184
|
+
options: { trustedRoot: string; overwrite?: boolean },
|
|
185
|
+
): void {
|
|
186
|
+
ensurePrivateArtifactDirectory(path.dirname(filePath), options.trustedRoot);
|
|
187
|
+
let existing: fs.Stats | undefined;
|
|
188
|
+
try {
|
|
189
|
+
existing = fs.lstatSync(filePath);
|
|
190
|
+
} catch (error: any) {
|
|
191
|
+
if (error?.code !== "ENOENT") throw error;
|
|
192
|
+
}
|
|
193
|
+
if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
|
|
194
|
+
throw new Error(`workflow artifact must be a regular file and may not be a symlink: ${filePath}`);
|
|
195
|
+
}
|
|
196
|
+
if (existing && !options.overwrite) {
|
|
197
|
+
throw new Error(`workflow artifact already exists: ${filePath}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const tempPath = path.join(
|
|
201
|
+
path.dirname(filePath),
|
|
202
|
+
`.${path.basename(filePath)}.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`,
|
|
203
|
+
);
|
|
204
|
+
let fd: number | undefined;
|
|
205
|
+
try {
|
|
206
|
+
fd = fs.openSync(
|
|
207
|
+
tempPath,
|
|
208
|
+
fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY | NO_FOLLOW,
|
|
209
|
+
0o600,
|
|
210
|
+
);
|
|
211
|
+
fs.writeFileSync(fd, data);
|
|
212
|
+
fs.fsyncSync(fd);
|
|
213
|
+
fs.closeSync(fd);
|
|
214
|
+
fd = undefined;
|
|
215
|
+
fs.renameSync(tempPath, filePath);
|
|
216
|
+
fsyncArtifactDirectory(path.dirname(filePath));
|
|
217
|
+
} finally {
|
|
218
|
+
if (fd !== undefined) {
|
|
219
|
+
try {
|
|
220
|
+
fs.closeSync(fd);
|
|
221
|
+
} catch {
|
|
222
|
+
// best-effort descriptor cleanup
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
try {
|
|
226
|
+
fs.rmSync(tempPath, { force: true });
|
|
227
|
+
} catch {
|
|
228
|
+
// best-effort temporary-file cleanup
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|