pi-long-task 0.1.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/LICENSE +21 -0
- package/README.md +126 -0
- package/package.json +63 -0
- package/scripts/native_smoke.mjs +323 -0
- package/src/coordinator.ts +633 -0
- package/src/git.ts +262 -0
- package/src/index.ts +63 -0
- package/src/render.ts +270 -0
- package/src/result_writer.ts +96 -0
- package/src/todo_generator.ts +304 -0
- package/src/todo_parser.ts +229 -0
- package/src/types.ts +60 -0
- package/src/worker_session.ts +836 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dejan Jacimovic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Pi Long Task
|
|
2
|
+
|
|
3
|
+
Pi Long Task is a Pi extension that turns a larger coding request into a clear TODO plan, works through the tasks one by one, and reports the result.
|
|
4
|
+
|
|
5
|
+
It is useful when you want Pi to handle a multi-step change without losing track of what has been done, what is still left, and whether changes should be committed.
|
|
6
|
+
|
|
7
|
+
## What you get
|
|
8
|
+
|
|
9
|
+
When you ask Pi to use `pi_long_task`, it will:
|
|
10
|
+
|
|
11
|
+
1. Create or clean up a TODO plan from your request.
|
|
12
|
+
2. Work through each unfinished TODO task in order.
|
|
13
|
+
3. Record progress and final results under `tmp/pi-long-task/<run-id>/`.
|
|
14
|
+
4. Return a summary with completed, failed, blocked, and remaining task counts.
|
|
15
|
+
5. Optionally commit completed work after each task.
|
|
16
|
+
|
|
17
|
+
A finished run gives you:
|
|
18
|
+
|
|
19
|
+
- a concise status summary in Pi
|
|
20
|
+
- a generated `TODO.md`
|
|
21
|
+
- a generated `TASK_RESULT.md`
|
|
22
|
+
- commit hashes when commits were enabled and created
|
|
23
|
+
- any remaining or blocked tasks clearly listed
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
After this package is published to npm, install it with:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi install npm:pi-long-task
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
For local development, load this checkout for one Pi session:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pi -e /path/to/pi-long-task
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Or install the local checkout so Pi can load it normally:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pi install /path/to/pi-long-task
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
After installing, start `pi` in your target project and ask it to use the `pi_long_task` tool.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
Run without commits:
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
Use pi_long_task with inputText "add tests for the parser and fix any failures" and commit false.
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Run and allow commits:
|
|
56
|
+
|
|
57
|
+
```text
|
|
58
|
+
Use pi_long_task with inputText "implement the TODOs in @TODO.md" and commit true.
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use a pasted TODO plan:
|
|
62
|
+
|
|
63
|
+
```text
|
|
64
|
+
Use pi_long_task with inputText "<paste TODO markdown here>" and commit false.
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Options
|
|
68
|
+
|
|
69
|
+
The tool has two inputs:
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
{
|
|
73
|
+
inputText: string;
|
|
74
|
+
commit: boolean;
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- `inputText` is the request or TODO markdown to work on.
|
|
79
|
+
- `commit` controls whether Pi Long Task may create git commits.
|
|
80
|
+
|
|
81
|
+
No other public options are required.
|
|
82
|
+
|
|
83
|
+
## Commits and files
|
|
84
|
+
|
|
85
|
+
When `commit` is `false`, Pi Long Task never creates commits.
|
|
86
|
+
|
|
87
|
+
When `commit` is `true`, it may commit eligible task changes after a task reports useful progress. It avoids committing:
|
|
88
|
+
|
|
89
|
+
- generated run files under `tmp/pi-long-task/`
|
|
90
|
+
- generated `TASK_RESULT.md` files
|
|
91
|
+
- files that were already dirty before the task started
|
|
92
|
+
|
|
93
|
+
This lets you keep existing local work separate from Pi Long Task changes.
|
|
94
|
+
|
|
95
|
+
## Validate the install
|
|
96
|
+
|
|
97
|
+
Run the local checks:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
cd /path/to/pi-long-task
|
|
101
|
+
npm run check
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Check that Pi can load the extension:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
PI_OFFLINE=1 pi --mode json --no-extensions -e /path/to/pi-long-task --no-session
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Run the full native smoke test if Pi has usable model credentials:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm run smoke:native
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
That smoke test creates disposable git repos and verifies both `commit: false` and `commit: true` runs.
|
|
117
|
+
|
|
118
|
+
## Notes
|
|
119
|
+
|
|
120
|
+
- Tasks run one at a time.
|
|
121
|
+
- Real runs require a working Pi model/login or API key.
|
|
122
|
+
- Run artifacts are written under `tmp/pi-long-task/<run-id>/`.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT. See [LICENSE](LICENSE).
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-long-task",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Pi extension for breaking down and running long coding tasks safely.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"long-task",
|
|
9
|
+
"task-runner",
|
|
10
|
+
"coding-agent",
|
|
11
|
+
"pi-extension"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
"LICENSE",
|
|
15
|
+
"README.md",
|
|
16
|
+
"scripts",
|
|
17
|
+
"src"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --experimental-strip-types --test",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"lint": "eslint .",
|
|
23
|
+
"format": "prettier --write .",
|
|
24
|
+
"format:check": "prettier --check .",
|
|
25
|
+
"check": "npm run format:check && npm run lint && npm run typecheck && npm test",
|
|
26
|
+
"smoke:native": "node scripts/native_smoke.mjs"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./src/index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@earendil-works/pi-ai": "*",
|
|
35
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
36
|
+
"@earendil-works/pi-tui": "*",
|
|
37
|
+
"typebox": "*"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@earendil-works/pi-ai": "^0.74.2",
|
|
41
|
+
"@earendil-works/pi-coding-agent": "^0.74.2",
|
|
42
|
+
"@earendil-works/pi-tui": "^0.74.2",
|
|
43
|
+
"@eslint/js": "^10.0.1",
|
|
44
|
+
"@types/node": "^25.9.3",
|
|
45
|
+
"eslint": "^10.5.0",
|
|
46
|
+
"eslint-config-prettier": "^10.1.8",
|
|
47
|
+
"globals": "^17.6.0",
|
|
48
|
+
"prettier": "^3.8.4",
|
|
49
|
+
"typebox": "^1.2.13",
|
|
50
|
+
"typescript": "^6.0.3",
|
|
51
|
+
"typescript-eslint": "^8.61.1"
|
|
52
|
+
},
|
|
53
|
+
"license": "MIT",
|
|
54
|
+
"author": "Dejan Jacimovic <dejan@stuntcoders.com>",
|
|
55
|
+
"repository": {
|
|
56
|
+
"type": "git",
|
|
57
|
+
"url": "git+https://github.com/thestuntcoder/pi-long-task.git"
|
|
58
|
+
},
|
|
59
|
+
"bugs": {
|
|
60
|
+
"url": "https://github.com/thestuntcoder/pi-long-task/issues"
|
|
61
|
+
},
|
|
62
|
+
"homepage": "https://github.com/thestuntcoder/pi-long-task#readme"
|
|
63
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtemp, rm, mkdir } from "node:fs/promises";
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const repoRoot = path.resolve(scriptDir, "..");
|
|
11
|
+
|
|
12
|
+
const PI_BINARY = process.env.PI_BINARY || "pi";
|
|
13
|
+
const EXTENSION_PATH = path.resolve(process.env.PI_COORDINATOR_EXTENSION || repoRoot);
|
|
14
|
+
const MODEL = process.env.PI_SMOKE_MODEL || "openai-codex/gpt-5.5:minimal";
|
|
15
|
+
const TIMEOUT_MS = positiveInteger(process.env.PI_SMOKE_TIMEOUT_MS, 20 * 60 * 1000);
|
|
16
|
+
const KEEP = truthy(process.env.PI_SMOKE_KEEP);
|
|
17
|
+
|
|
18
|
+
const cases = [
|
|
19
|
+
{
|
|
20
|
+
name: "commit-false",
|
|
21
|
+
commit: false,
|
|
22
|
+
markerFile: "SMOKE_FALSE.txt",
|
|
23
|
+
markerText: "commit false smoke ok",
|
|
24
|
+
title: "Create non-commit smoke marker",
|
|
25
|
+
goal: "Verify Pi Long Task can modify the disposable repo while commits are disabled.",
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "commit-true",
|
|
29
|
+
commit: true,
|
|
30
|
+
markerFile: "SMOKE_TRUE.txt",
|
|
31
|
+
markerText: "commit true smoke ok",
|
|
32
|
+
title: "Create commit smoke marker",
|
|
33
|
+
goal: "Verify Pi Long Task can modify the disposable repo and commit eligible changes.",
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const smokeRoot = await mkdtemp(path.join(os.tmpdir(), "pi-long-task-native-smoke-"));
|
|
38
|
+
let success = false;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
console.log(`Native Pi Long Task smoke root: ${smokeRoot}`);
|
|
42
|
+
console.log(`Extension: ${EXTENSION_PATH}`);
|
|
43
|
+
console.log(`Outer model: ${MODEL}`);
|
|
44
|
+
|
|
45
|
+
const summaries = [];
|
|
46
|
+
for (const testCase of cases) {
|
|
47
|
+
summaries.push(await runSmokeCase(testCase));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
success = true;
|
|
51
|
+
console.log("\nNative Pi Long Task smoke passed:");
|
|
52
|
+
for (const summary of summaries) {
|
|
53
|
+
const commitText = summary.commitHash ? `, Pi Long Task commit ${summary.commitHash}` : "";
|
|
54
|
+
console.log(`- ${summary.name}: ${summary.status}, marker verified${commitText}`);
|
|
55
|
+
}
|
|
56
|
+
} finally {
|
|
57
|
+
if (success && !KEEP) {
|
|
58
|
+
await rm(smokeRoot, { recursive: true, force: true });
|
|
59
|
+
} else {
|
|
60
|
+
console.log(`Smoke artifacts kept at: ${smokeRoot}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function runSmokeCase(testCase) {
|
|
65
|
+
const caseDir = path.join(smokeRoot, testCase.name);
|
|
66
|
+
const repoDir = path.join(caseDir, "repo");
|
|
67
|
+
await mkdir(repoDir, { recursive: true });
|
|
68
|
+
initializeGitRepo(repoDir, testCase.name);
|
|
69
|
+
|
|
70
|
+
const prompt = buildPrompt(testCase);
|
|
71
|
+
const stdoutPath = path.join(caseDir, "pi.stdout.jsonl");
|
|
72
|
+
const stderrPath = path.join(caseDir, "pi.stderr.log");
|
|
73
|
+
const args = [
|
|
74
|
+
"-p",
|
|
75
|
+
"--mode",
|
|
76
|
+
"json",
|
|
77
|
+
"--no-session",
|
|
78
|
+
"--no-extensions",
|
|
79
|
+
"-e",
|
|
80
|
+
EXTENSION_PATH,
|
|
81
|
+
"--no-context-files",
|
|
82
|
+
"--no-skills",
|
|
83
|
+
"--no-prompt-templates",
|
|
84
|
+
"--tools",
|
|
85
|
+
"pi_long_task",
|
|
86
|
+
"--model",
|
|
87
|
+
MODEL,
|
|
88
|
+
prompt,
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
console.log(`\n[${testCase.name}] running Pi in ${repoDir}`);
|
|
92
|
+
const result = await runProcess(PI_BINARY, args, {
|
|
93
|
+
cwd: repoDir,
|
|
94
|
+
env: {
|
|
95
|
+
...process.env,
|
|
96
|
+
PI_SKIP_VERSION_CHECK: process.env.PI_SKIP_VERSION_CHECK || "1",
|
|
97
|
+
},
|
|
98
|
+
timeoutMs: TIMEOUT_MS,
|
|
99
|
+
stdoutPath,
|
|
100
|
+
stderrPath,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
if (result.code !== 0) {
|
|
104
|
+
throw new Error(`[${testCase.name}] pi exited with ${result.code}. stdout: ${stdoutPath}; stderr: ${stderrPath}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const events = parseJsonEvents(result.stdout);
|
|
108
|
+
const longTaskEnd = events.find((event) => event.type === "tool_execution_end" && event.toolName === "pi_long_task");
|
|
109
|
+
if (!longTaskEnd) {
|
|
110
|
+
throw new Error(`[${testCase.name}] did not observe pi_long_task tool_execution_end in ${stdoutPath}`);
|
|
111
|
+
}
|
|
112
|
+
if (longTaskEnd.isError) {
|
|
113
|
+
throw new Error(`[${testCase.name}] pi_long_task ended with isError=true. See ${stdoutPath}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const details = longTaskEnd.result?.details;
|
|
117
|
+
assertLongTaskDetails(testCase, details, stdoutPath);
|
|
118
|
+
assertMarker(repoDir, testCase.markerFile, testCase.markerText);
|
|
119
|
+
assertGitState(repoDir, testCase, details);
|
|
120
|
+
|
|
121
|
+
const summary = {
|
|
122
|
+
name: testCase.name,
|
|
123
|
+
status: details.status,
|
|
124
|
+
commitHash: details.commits?.[0]?.hash,
|
|
125
|
+
};
|
|
126
|
+
console.log(`[${testCase.name}] ok; stdout: ${stdoutPath}`);
|
|
127
|
+
return summary;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function initializeGitRepo(repoDir, caseName) {
|
|
131
|
+
runGit(["init", "-q"], repoDir);
|
|
132
|
+
runGit(["config", "user.email", "smoke@example.invalid"], repoDir);
|
|
133
|
+
runGit(["config", "user.name", "Pi Long Task Smoke"], repoDir);
|
|
134
|
+
writeFileSync(path.join(repoDir, "README.md"), `# ${caseName}\n`, "utf8");
|
|
135
|
+
runGit(["add", "README.md"], repoDir);
|
|
136
|
+
runGit(["commit", "-q", "-m", "init"], repoDir);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function buildPrompt(testCase) {
|
|
140
|
+
return `Call the \`pi_long_task\` tool exactly once. Do not use any other tool and do not answer from your own knowledge.
|
|
141
|
+
Use these exact parameters:
|
|
142
|
+
- commit: ${String(testCase.commit)}
|
|
143
|
+
- inputText:
|
|
144
|
+
\`\`\`markdown
|
|
145
|
+
# TODO
|
|
146
|
+
|
|
147
|
+
## TODO 1 — ${testCase.title}
|
|
148
|
+
|
|
149
|
+
**Goal:** ${testCase.goal}
|
|
150
|
+
|
|
151
|
+
**Status:**
|
|
152
|
+
- [ ] Create a file named \`${testCase.markerFile}\` containing exactly \`${testCase.markerText}\` followed by a newline.
|
|
153
|
+
- [ ] Verify it by running \`cat ${testCase.markerFile}\`.
|
|
154
|
+
\`\`\``;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function assertLongTaskDetails(testCase, details, stdoutPath) {
|
|
158
|
+
if (!details || typeof details !== "object") {
|
|
159
|
+
throw new Error(`[${testCase.name}] missing Pi Long Task result details. See ${stdoutPath}`);
|
|
160
|
+
}
|
|
161
|
+
const expected = {
|
|
162
|
+
status: "done",
|
|
163
|
+
totalTasks: 1,
|
|
164
|
+
completedTasks: 1,
|
|
165
|
+
failedTasks: 0,
|
|
166
|
+
blockedTasks: 0,
|
|
167
|
+
};
|
|
168
|
+
for (const [key, value] of Object.entries(expected)) {
|
|
169
|
+
if (details[key] !== value) {
|
|
170
|
+
throw new Error(`[${testCase.name}] expected details.${key}=${value}, got ${details[key]}. See ${stdoutPath}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const commitCount = Array.isArray(details.commits) ? details.commits.length : 0;
|
|
174
|
+
if (testCase.commit && commitCount !== 1) {
|
|
175
|
+
throw new Error(`[${testCase.name}] expected one Pi Long Task commit, got ${commitCount}. See ${stdoutPath}`);
|
|
176
|
+
}
|
|
177
|
+
if (!testCase.commit && commitCount !== 0) {
|
|
178
|
+
throw new Error(`[${testCase.name}] expected zero Pi Long Task commits, got ${commitCount}. See ${stdoutPath}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function assertMarker(repoDir, markerFile, markerText) {
|
|
183
|
+
const markerPath = path.join(repoDir, markerFile);
|
|
184
|
+
if (!existsSync(markerPath)) {
|
|
185
|
+
throw new Error(`missing smoke marker ${markerPath}`);
|
|
186
|
+
}
|
|
187
|
+
const actual = readFileSync(markerPath, "utf8");
|
|
188
|
+
const expected = `${markerText}\n`;
|
|
189
|
+
if (actual !== expected) {
|
|
190
|
+
throw new Error(`unexpected marker contents in ${markerPath}: ${JSON.stringify(actual)}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function assertGitState(repoDir, testCase, details) {
|
|
195
|
+
const commitCount = Number(runGit(["rev-list", "--count", "HEAD"], repoDir).trim());
|
|
196
|
+
const status = runGit(["status", "--short", "--untracked-files=all"], repoDir);
|
|
197
|
+
|
|
198
|
+
if (testCase.commit) {
|
|
199
|
+
if (commitCount !== 2) {
|
|
200
|
+
throw new Error(`[${testCase.name}] expected exactly 2 commits after commit=true run, got ${commitCount}`);
|
|
201
|
+
}
|
|
202
|
+
const changedInHead = runGit(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], repoDir)
|
|
203
|
+
.trim()
|
|
204
|
+
.split("\n")
|
|
205
|
+
.filter(Boolean)
|
|
206
|
+
.sort();
|
|
207
|
+
if (JSON.stringify(changedInHead) !== JSON.stringify([testCase.markerFile])) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`[${testCase.name}] expected HEAD to contain only ${testCase.markerFile}, got ${changedInHead.join(", ")}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
if (!details.commits?.[0]?.hash) {
|
|
213
|
+
throw new Error(`[${testCase.name}] missing commit hash in Pi Long Task details`);
|
|
214
|
+
}
|
|
215
|
+
if (status.includes(testCase.markerFile)) {
|
|
216
|
+
throw new Error(`[${testCase.name}] committed marker still appears dirty in git status:\n${status}`);
|
|
217
|
+
}
|
|
218
|
+
} else {
|
|
219
|
+
if (commitCount !== 1) {
|
|
220
|
+
throw new Error(`[${testCase.name}] expected no new commits after commit=false run, got ${commitCount}`);
|
|
221
|
+
}
|
|
222
|
+
if (!status.includes(`?? ${testCase.markerFile}`)) {
|
|
223
|
+
throw new Error(`[${testCase.name}] expected uncommitted marker in git status, got:\n${status}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const trackedArtifacts = runGit(["ls-files", "tmp/pi-long-task"], repoDir).trim();
|
|
228
|
+
if (trackedArtifacts) {
|
|
229
|
+
throw new Error(`[${testCase.name}] Pi Long Task artifacts were tracked unexpectedly:\n${trackedArtifacts}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function runGit(args, cwd) {
|
|
234
|
+
const result = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
235
|
+
if (result.status !== 0) {
|
|
236
|
+
throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${result.stderr || result.stdout}`);
|
|
237
|
+
}
|
|
238
|
+
return result.stdout;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function runProcess(command, args, options) {
|
|
242
|
+
return new Promise((resolve, reject) => {
|
|
243
|
+
const child = spawn(command, args, {
|
|
244
|
+
cwd: options.cwd,
|
|
245
|
+
env: options.env,
|
|
246
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
247
|
+
});
|
|
248
|
+
let stdout = "";
|
|
249
|
+
let stderr = "";
|
|
250
|
+
let settled = false;
|
|
251
|
+
|
|
252
|
+
const timeout = setTimeout(() => {
|
|
253
|
+
if (settled) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
settled = true;
|
|
257
|
+
child.kill("SIGTERM");
|
|
258
|
+
setTimeout(() => child.kill("SIGKILL"), 5_000).unref();
|
|
259
|
+
reject(new Error(`${command} timed out after ${options.timeoutMs}ms`));
|
|
260
|
+
}, options.timeoutMs);
|
|
261
|
+
|
|
262
|
+
child.stdout.setEncoding("utf8");
|
|
263
|
+
child.stderr.setEncoding("utf8");
|
|
264
|
+
child.stdout.on("data", (chunk) => {
|
|
265
|
+
stdout += chunk;
|
|
266
|
+
appendFileSync(options.stdoutPath, chunk);
|
|
267
|
+
});
|
|
268
|
+
child.stderr.on("data", (chunk) => {
|
|
269
|
+
stderr += chunk;
|
|
270
|
+
appendFileSync(options.stderrPath, chunk);
|
|
271
|
+
});
|
|
272
|
+
child.on("error", (error) => {
|
|
273
|
+
if (settled) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
settled = true;
|
|
277
|
+
clearTimeout(timeout);
|
|
278
|
+
reject(error);
|
|
279
|
+
});
|
|
280
|
+
child.on("close", (code, signal) => {
|
|
281
|
+
if (settled) {
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
settled = true;
|
|
285
|
+
clearTimeout(timeout);
|
|
286
|
+
resolve({ code, signal, stdout, stderr });
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function appendFileSync(filePath, chunk) {
|
|
292
|
+
writeFileSync(filePath, chunk, { encoding: "utf8", flag: "a" });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function parseJsonEvents(stdout) {
|
|
296
|
+
const events = [];
|
|
297
|
+
for (const rawLine of stdout.split(/\n/)) {
|
|
298
|
+
const jsonStart = rawLine.indexOf("{");
|
|
299
|
+
if (jsonStart === -1) {
|
|
300
|
+
continue;
|
|
301
|
+
}
|
|
302
|
+
const line = rawLine.slice(jsonStart).trim();
|
|
303
|
+
if (!line) {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
try {
|
|
307
|
+
events.push(JSON.parse(line));
|
|
308
|
+
} catch {
|
|
309
|
+
// Pi may emit terminal control sequences next to JSON in some terminals.
|
|
310
|
+
// Keep parsing best-effort; missing required events are asserted later.
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return events;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function truthy(value) {
|
|
317
|
+
return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase());
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function positiveInteger(value, fallback) {
|
|
321
|
+
const parsed = Number(value);
|
|
322
|
+
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
323
|
+
}
|