harness-async 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 +152 -0
- package/dist/dashboard/assets/index-TGNGdtwt.js +246 -0
- package/dist/dashboard/assets/index-f4TpA4iP.css +1 -0
- package/dist/dashboard/index.html +13 -0
- package/dist/src/adapters/claude-adapter.js +52 -0
- package/dist/src/adapters/codex-adapter.js +55 -0
- package/dist/src/adapters/index.js +14 -0
- package/dist/src/adapters/shared.js +74 -0
- package/dist/src/cli/commands/daemon.js +116 -0
- package/dist/src/cli/commands/doctor.js +50 -0
- package/dist/src/cli/commands/hook.js +188 -0
- package/dist/src/cli/commands/init.js +22 -0
- package/dist/src/cli/commands/run.js +129 -0
- package/dist/src/cli/commands/schedule.js +105 -0
- package/dist/src/cli/commands/task.js +188 -0
- package/dist/src/cli/index.js +23 -0
- package/dist/src/cli/utils/notify.js +32 -0
- package/dist/src/cli/utils/output.js +94 -0
- package/dist/src/core/daemon.js +375 -0
- package/dist/src/core/dag.js +80 -0
- package/dist/src/core/event-log.js +34 -0
- package/dist/src/core/lock.js +25 -0
- package/dist/src/core/run-manager.js +265 -0
- package/dist/src/core/run-orchestrator.js +193 -0
- package/dist/src/core/scheduler.js +106 -0
- package/dist/src/core/sessions.js +48 -0
- package/dist/src/core/store.js +225 -0
- package/dist/src/core/task-manager.js +375 -0
- package/dist/src/core/tmux.js +51 -0
- package/dist/src/daemon.js +35 -0
- package/dist/src/dashboard/routes.js +107 -0
- package/dist/src/dashboard/server.js +142 -0
- package/dist/src/dashboard/ws.js +75 -0
- package/dist/src/types/adapter.js +30 -0
- package/dist/src/types/index.js +87 -0
- package/package.json +65 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { appendEvent } from './event-log.js';
|
|
4
|
+
import { findTaskLocation, getTask, updateTaskStatus } from './task-manager.js';
|
|
5
|
+
import { resolveStoreDir } from './store.js';
|
|
6
|
+
import { runSchema } from '../types/index.js';
|
|
7
|
+
const META_FILE = 'meta.json';
|
|
8
|
+
const PROMPT_FILE = 'prompt.txt';
|
|
9
|
+
const STDOUT_FILE = 'stdout.log';
|
|
10
|
+
const EXIT_CODE_FILE = 'exit.code';
|
|
11
|
+
export async function startRun(input) {
|
|
12
|
+
const task = await getTask({
|
|
13
|
+
cwd: input.cwd,
|
|
14
|
+
homeDir: input.homeDir,
|
|
15
|
+
id: input.taskId,
|
|
16
|
+
scope: input.scope ?? 'project',
|
|
17
|
+
});
|
|
18
|
+
const location = await findTaskLocation({
|
|
19
|
+
cwd: input.cwd,
|
|
20
|
+
homeDir: input.homeDir,
|
|
21
|
+
id: input.taskId,
|
|
22
|
+
scope: input.scope ?? 'project',
|
|
23
|
+
});
|
|
24
|
+
if (!location) {
|
|
25
|
+
throw new Error(`Task ${input.taskId} not found`);
|
|
26
|
+
}
|
|
27
|
+
const now = input.now ?? new Date().toISOString();
|
|
28
|
+
const runsRoot = join(location.storeDir, 'runs');
|
|
29
|
+
await mkdir(runsRoot, { recursive: true });
|
|
30
|
+
const sequence = (await listRunsInStore(location.storeDir, input.taskId)).length + 1;
|
|
31
|
+
const runId = `${toFilesystemTimestamp(now)}-task-${String(input.taskId).padStart(3, '0')}-run-${sequence}`;
|
|
32
|
+
const runDir = join(runsRoot, runId);
|
|
33
|
+
const promptPath = join(runDir, PROMPT_FILE);
|
|
34
|
+
const stdoutPath = join(runDir, STDOUT_FILE);
|
|
35
|
+
const exitCodePath = join(runDir, EXIT_CODE_FILE);
|
|
36
|
+
const metaPath = join(runDir, META_FILE);
|
|
37
|
+
const run = runSchema.parse({
|
|
38
|
+
id: runId,
|
|
39
|
+
taskId: input.taskId,
|
|
40
|
+
tool: input.tool,
|
|
41
|
+
status: 'running',
|
|
42
|
+
tmuxSession: `ha-task-${String(input.taskId).padStart(3, '0')}-run-${sequence}`,
|
|
43
|
+
startedAt: now,
|
|
44
|
+
completedAt: null,
|
|
45
|
+
exitCode: null,
|
|
46
|
+
directory: input.directory,
|
|
47
|
+
scope: task.scope,
|
|
48
|
+
runDir,
|
|
49
|
+
stdoutPath,
|
|
50
|
+
exitCodePath,
|
|
51
|
+
promptPath,
|
|
52
|
+
});
|
|
53
|
+
await mkdir(runDir, { recursive: true });
|
|
54
|
+
await writeFile(promptPath, input.prompt ?? '', 'utf8');
|
|
55
|
+
await writeFile(stdoutPath, '', 'utf8');
|
|
56
|
+
await writeFile(metaPath, `${JSON.stringify(run, null, 2)}\n`, 'utf8');
|
|
57
|
+
try {
|
|
58
|
+
await updateTaskStatus({
|
|
59
|
+
cwd: input.cwd,
|
|
60
|
+
homeDir: input.homeDir,
|
|
61
|
+
id: input.taskId,
|
|
62
|
+
scope: task.scope,
|
|
63
|
+
status: 'running',
|
|
64
|
+
actor: input.tool,
|
|
65
|
+
});
|
|
66
|
+
await appendEvent(location.storeDir, {
|
|
67
|
+
ts: now,
|
|
68
|
+
type: 'run.started',
|
|
69
|
+
taskId: input.taskId,
|
|
70
|
+
actor: input.tool,
|
|
71
|
+
detail: {
|
|
72
|
+
runId,
|
|
73
|
+
tool: input.tool,
|
|
74
|
+
session: run.tmuxSession,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
await rm(runDir, { recursive: true, force: true });
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
return run;
|
|
83
|
+
}
|
|
84
|
+
export async function completeRun(input) {
|
|
85
|
+
const located = await findRun(input);
|
|
86
|
+
const now = input.now ?? new Date().toISOString();
|
|
87
|
+
const nextStatus = input.exitCode === 0 ? 'completed' : 'failed';
|
|
88
|
+
const nextRun = runSchema.parse({
|
|
89
|
+
...located.run,
|
|
90
|
+
status: nextStatus,
|
|
91
|
+
completedAt: now,
|
|
92
|
+
exitCode: input.exitCode,
|
|
93
|
+
});
|
|
94
|
+
await writeRunMeta(nextRun);
|
|
95
|
+
const task = await getTask({
|
|
96
|
+
cwd: input.cwd,
|
|
97
|
+
homeDir: input.homeDir,
|
|
98
|
+
id: nextRun.taskId,
|
|
99
|
+
scope: nextRun.scope,
|
|
100
|
+
});
|
|
101
|
+
const targetStatus = input.exitCode === 0
|
|
102
|
+
? task.level === 'L2'
|
|
103
|
+
? 'waiting-review'
|
|
104
|
+
: 'completed'
|
|
105
|
+
: 'failed';
|
|
106
|
+
if (task.status === 'running') {
|
|
107
|
+
await updateTaskStatus({
|
|
108
|
+
cwd: input.cwd,
|
|
109
|
+
homeDir: input.homeDir,
|
|
110
|
+
id: nextRun.taskId,
|
|
111
|
+
scope: nextRun.scope,
|
|
112
|
+
status: targetStatus,
|
|
113
|
+
actor: nextRun.tool,
|
|
114
|
+
reason: input.exitCode === 0 ? undefined : `Run ${nextRun.id} exited with code ${input.exitCode}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
await appendEvent(located.storeDir, {
|
|
118
|
+
ts: now,
|
|
119
|
+
type: input.exitCode === 0 ? 'run.completed' : 'run.failed',
|
|
120
|
+
taskId: nextRun.taskId,
|
|
121
|
+
actor: nextRun.tool,
|
|
122
|
+
detail: {
|
|
123
|
+
runId: nextRun.id,
|
|
124
|
+
exitCode: input.exitCode,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
return nextRun;
|
|
128
|
+
}
|
|
129
|
+
export async function listRuns(input) {
|
|
130
|
+
const storeDirs = await resolveRunStores(input);
|
|
131
|
+
const runs = (await Promise.all(storeDirs.map(async (storeDir) => listRunsInStore(storeDir, input.taskId)))).flat();
|
|
132
|
+
return [...runs].sort((left, right) => right.startedAt.localeCompare(left.startedAt));
|
|
133
|
+
}
|
|
134
|
+
export async function getRun(input) {
|
|
135
|
+
const located = await findRun({
|
|
136
|
+
cwd: input.cwd,
|
|
137
|
+
homeDir: input.homeDir,
|
|
138
|
+
runId: input.runId,
|
|
139
|
+
scope: input.scope,
|
|
140
|
+
});
|
|
141
|
+
return located.run;
|
|
142
|
+
}
|
|
143
|
+
export async function readRunOutput(run) {
|
|
144
|
+
try {
|
|
145
|
+
return await readFile(run.stdoutPath, 'utf8');
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code === 'ENOENT') {
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
export async function readRunExitCode(run) {
|
|
155
|
+
try {
|
|
156
|
+
const raw = await readFile(run.exitCodePath, 'utf8');
|
|
157
|
+
const trimmed = raw.trim();
|
|
158
|
+
return trimmed.length > 0 ? Number(trimmed) : null;
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
if (error.code === 'ENOENT') {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
throw error;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export async function updateRunStatus(run, status, options = {}) {
|
|
168
|
+
const nextRun = runSchema.parse({
|
|
169
|
+
...run,
|
|
170
|
+
status,
|
|
171
|
+
exitCode: options.exitCode ?? run.exitCode,
|
|
172
|
+
completedAt: options.completedAt ?? run.completedAt,
|
|
173
|
+
});
|
|
174
|
+
await writeRunMeta(nextRun);
|
|
175
|
+
return nextRun;
|
|
176
|
+
}
|
|
177
|
+
async function writeRunMeta(run) {
|
|
178
|
+
await mkdir(run.runDir, { recursive: true });
|
|
179
|
+
await writeFile(join(run.runDir, META_FILE), `${JSON.stringify(run, null, 2)}\n`, 'utf8');
|
|
180
|
+
}
|
|
181
|
+
async function findRun(input) {
|
|
182
|
+
const storeDirs = await resolveRunStores({
|
|
183
|
+
cwd: input.cwd,
|
|
184
|
+
homeDir: input.homeDir,
|
|
185
|
+
scope: input.scope ?? 'all',
|
|
186
|
+
});
|
|
187
|
+
for (const storeDir of storeDirs) {
|
|
188
|
+
const filePath = join(storeDir, 'runs', input.runId, META_FILE);
|
|
189
|
+
try {
|
|
190
|
+
const raw = await readFile(filePath, 'utf8');
|
|
191
|
+
return {
|
|
192
|
+
run: runSchema.parse(JSON.parse(raw)),
|
|
193
|
+
storeDir,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error.code === 'ENOENT') {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
throw new Error(`Run ${input.runId} not found`);
|
|
204
|
+
}
|
|
205
|
+
async function listRunsInStore(storeDir, taskId) {
|
|
206
|
+
const runsDir = join(storeDir, 'runs');
|
|
207
|
+
try {
|
|
208
|
+
const entries = await readdir(runsDir, { withFileTypes: true });
|
|
209
|
+
const runs = await Promise.all(entries
|
|
210
|
+
.filter((entry) => entry.isDirectory())
|
|
211
|
+
.map(async (entry) => {
|
|
212
|
+
const raw = await readFile(join(runsDir, entry.name, META_FILE), 'utf8');
|
|
213
|
+
return runSchema.parse(JSON.parse(raw));
|
|
214
|
+
}));
|
|
215
|
+
return runs.filter((run) => (taskId ? run.taskId === taskId : true));
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
if (error.code === 'ENOENT') {
|
|
219
|
+
return [];
|
|
220
|
+
}
|
|
221
|
+
throw error;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function resolveRunStores(input) {
|
|
225
|
+
const candidates = [
|
|
226
|
+
{
|
|
227
|
+
scope: 'project',
|
|
228
|
+
baseDir: resolveStoreDir({
|
|
229
|
+
cwd: input.cwd,
|
|
230
|
+
homeDir: input.homeDir,
|
|
231
|
+
scope: 'project',
|
|
232
|
+
}),
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
scope: 'global',
|
|
236
|
+
baseDir: resolveStoreDir({
|
|
237
|
+
cwd: input.cwd,
|
|
238
|
+
homeDir: input.homeDir,
|
|
239
|
+
scope: 'global',
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
if (input.scope === 'project') {
|
|
244
|
+
return [candidates[0].baseDir];
|
|
245
|
+
}
|
|
246
|
+
if (input.scope === 'global') {
|
|
247
|
+
return [candidates[1].baseDir];
|
|
248
|
+
}
|
|
249
|
+
return candidates.map((candidate) => candidate.baseDir);
|
|
250
|
+
}
|
|
251
|
+
function toFilesystemTimestamp(value) {
|
|
252
|
+
return new Date(value).toISOString().replace(/:/g, '-');
|
|
253
|
+
}
|
|
254
|
+
export function metaFilename() {
|
|
255
|
+
return META_FILE;
|
|
256
|
+
}
|
|
257
|
+
export function outputFilename() {
|
|
258
|
+
return STDOUT_FILE;
|
|
259
|
+
}
|
|
260
|
+
export function exitCodeFilename() {
|
|
261
|
+
return EXIT_CODE_FILE;
|
|
262
|
+
}
|
|
263
|
+
export function promptFilename() {
|
|
264
|
+
return PROMPT_FILE;
|
|
265
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { relative } from 'node:path';
|
|
3
|
+
import { createAgentAdapter } from '../adapters/index.js';
|
|
4
|
+
import { completeRun, getRun, readRunOutput, startRun } from './run-manager.js';
|
|
5
|
+
import { getTask, listRunnableTasks, findTaskLocation } from './task-manager.js';
|
|
6
|
+
import { attachTmuxSession } from './tmux.js';
|
|
7
|
+
export async function startTaskRun(input) {
|
|
8
|
+
const task = input.auto
|
|
9
|
+
? await pickRunnableTask(input)
|
|
10
|
+
: await getTask({
|
|
11
|
+
cwd: input.cwd,
|
|
12
|
+
homeDir: input.homeDir,
|
|
13
|
+
id: input.taskId ?? 0,
|
|
14
|
+
scope: input.scope ?? 'project',
|
|
15
|
+
});
|
|
16
|
+
const adapter = createAgentAdapter(input.tool, {
|
|
17
|
+
claudeBin: process.env.HA_CLAUDE_BIN,
|
|
18
|
+
codexBin: process.env.HA_CODEX_BIN,
|
|
19
|
+
tmuxBin: process.env.HA_TMUX_BIN,
|
|
20
|
+
});
|
|
21
|
+
const report = await adapter.detect();
|
|
22
|
+
if (!report.available) {
|
|
23
|
+
throw new Error(`Unable to find ${input.tool} on PATH`);
|
|
24
|
+
}
|
|
25
|
+
const prompt = await buildRunPrompt(task, input.cwd, input.homeDir);
|
|
26
|
+
const run = await startRun({
|
|
27
|
+
cwd: input.cwd,
|
|
28
|
+
homeDir: input.homeDir,
|
|
29
|
+
taskId: task.id,
|
|
30
|
+
tool: input.tool,
|
|
31
|
+
directory: task.project ?? input.cwd,
|
|
32
|
+
prompt,
|
|
33
|
+
scope: task.scope,
|
|
34
|
+
});
|
|
35
|
+
const context = await buildRunContext(run, input.cwd, input.homeDir);
|
|
36
|
+
try {
|
|
37
|
+
await adapter.start(run, context);
|
|
38
|
+
return run;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
await completeRun({
|
|
42
|
+
cwd: input.cwd,
|
|
43
|
+
homeDir: input.homeDir,
|
|
44
|
+
runId: run.id,
|
|
45
|
+
exitCode: 1,
|
|
46
|
+
});
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function syncRunState(context, runId) {
|
|
51
|
+
const run = await getRun({
|
|
52
|
+
cwd: context.cwd,
|
|
53
|
+
homeDir: context.homeDir,
|
|
54
|
+
runId,
|
|
55
|
+
scope: 'all',
|
|
56
|
+
});
|
|
57
|
+
if (run.status !== 'running') {
|
|
58
|
+
return run;
|
|
59
|
+
}
|
|
60
|
+
const adapter = createAgentAdapter(run.tool, {
|
|
61
|
+
claudeBin: process.env.HA_CLAUDE_BIN,
|
|
62
|
+
codexBin: process.env.HA_CODEX_BIN,
|
|
63
|
+
tmuxBin: process.env.HA_TMUX_BIN,
|
|
64
|
+
});
|
|
65
|
+
const snapshot = await adapter.collect(run);
|
|
66
|
+
if (snapshot.output.trim().length > 0) {
|
|
67
|
+
const currentOutput = await readRunOutput(run);
|
|
68
|
+
if (currentOutput.trim().length === 0) {
|
|
69
|
+
await writeFile(run.stdoutPath, snapshot.output, 'utf8');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (!snapshot.active && snapshot.exitCode !== null) {
|
|
73
|
+
return completeRun({
|
|
74
|
+
cwd: context.cwd,
|
|
75
|
+
homeDir: context.homeDir,
|
|
76
|
+
runId,
|
|
77
|
+
exitCode: snapshot.exitCode,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return run;
|
|
81
|
+
}
|
|
82
|
+
export async function stopTaskRun(context, runId) {
|
|
83
|
+
const run = await getRun({
|
|
84
|
+
cwd: context.cwd,
|
|
85
|
+
homeDir: context.homeDir,
|
|
86
|
+
runId,
|
|
87
|
+
scope: 'all',
|
|
88
|
+
});
|
|
89
|
+
const adapter = createAgentAdapter(run.tool, {
|
|
90
|
+
claudeBin: process.env.HA_CLAUDE_BIN,
|
|
91
|
+
codexBin: process.env.HA_CODEX_BIN,
|
|
92
|
+
tmuxBin: process.env.HA_TMUX_BIN,
|
|
93
|
+
});
|
|
94
|
+
await adapter.stop(run);
|
|
95
|
+
return completeRun({
|
|
96
|
+
cwd: context.cwd,
|
|
97
|
+
homeDir: context.homeDir,
|
|
98
|
+
runId,
|
|
99
|
+
exitCode: 130,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
export async function collectRunOutput(context, runId) {
|
|
103
|
+
const run = await syncRunState(context, runId);
|
|
104
|
+
const adapter = createAgentAdapter(run.tool, {
|
|
105
|
+
claudeBin: process.env.HA_CLAUDE_BIN,
|
|
106
|
+
codexBin: process.env.HA_CODEX_BIN,
|
|
107
|
+
tmuxBin: process.env.HA_TMUX_BIN,
|
|
108
|
+
});
|
|
109
|
+
const snapshot = await adapter.collect(run);
|
|
110
|
+
return snapshot.output || (await readRunOutput(run));
|
|
111
|
+
}
|
|
112
|
+
export async function getRunAttachCommand(context, runId) {
|
|
113
|
+
const run = await getRun({
|
|
114
|
+
cwd: context.cwd,
|
|
115
|
+
homeDir: context.homeDir,
|
|
116
|
+
runId,
|
|
117
|
+
scope: 'all',
|
|
118
|
+
});
|
|
119
|
+
return attachTmuxSession(run.tmuxSession);
|
|
120
|
+
}
|
|
121
|
+
export async function buildRunContext(run, cwd, homeDir) {
|
|
122
|
+
const task = await getTask({
|
|
123
|
+
cwd,
|
|
124
|
+
homeDir,
|
|
125
|
+
id: run.taskId,
|
|
126
|
+
scope: run.scope,
|
|
127
|
+
});
|
|
128
|
+
const prompt = await readRunOutputFile(run.promptPath);
|
|
129
|
+
const location = await findTaskLocation({
|
|
130
|
+
cwd,
|
|
131
|
+
homeDir,
|
|
132
|
+
id: run.taskId,
|
|
133
|
+
scope: run.scope,
|
|
134
|
+
});
|
|
135
|
+
if (!location) {
|
|
136
|
+
throw new Error(`Task ${run.taskId} not found`);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
cwd,
|
|
140
|
+
homeDir,
|
|
141
|
+
storeDir: location.storeDir,
|
|
142
|
+
runDir: run.runDir,
|
|
143
|
+
stdoutPath: run.stdoutPath,
|
|
144
|
+
exitCodePath: run.exitCodePath,
|
|
145
|
+
promptPath: run.promptPath,
|
|
146
|
+
task,
|
|
147
|
+
prompt,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export async function buildRunPrompt(task, cwd, homeDir) {
|
|
151
|
+
const location = await findTaskLocation({
|
|
152
|
+
cwd,
|
|
153
|
+
homeDir,
|
|
154
|
+
id: task.id,
|
|
155
|
+
scope: task.scope,
|
|
156
|
+
});
|
|
157
|
+
if (!location) {
|
|
158
|
+
throw new Error(`Task ${task.id} not found`);
|
|
159
|
+
}
|
|
160
|
+
const taskPath = relative(cwd, location.filePath) || location.filePath;
|
|
161
|
+
return [
|
|
162
|
+
`根据任务文档执行任务 #${task.id}: ${task.title}。`,
|
|
163
|
+
`任务文件: ${taskPath}`,
|
|
164
|
+
`完成后运行: ha task complete ${task.id}`,
|
|
165
|
+
`如果遇到问题,运行: ha task fail ${task.id} --reason "<具体原因>"`,
|
|
166
|
+
'',
|
|
167
|
+
task.body || 'No additional task body.',
|
|
168
|
+
].join('\n');
|
|
169
|
+
}
|
|
170
|
+
async function pickRunnableTask(input) {
|
|
171
|
+
const tasks = await listRunnableTasks({
|
|
172
|
+
cwd: input.cwd,
|
|
173
|
+
homeDir: input.homeDir,
|
|
174
|
+
scope: input.scope ?? 'project',
|
|
175
|
+
level: 'L1',
|
|
176
|
+
});
|
|
177
|
+
const task = tasks.find((entry) => entry.assignee === input.tool || entry.assignee === 'auto');
|
|
178
|
+
if (!task) {
|
|
179
|
+
throw new Error('No runnable L1 task is available');
|
|
180
|
+
}
|
|
181
|
+
return task;
|
|
182
|
+
}
|
|
183
|
+
async function readRunOutputFile(filePath) {
|
|
184
|
+
try {
|
|
185
|
+
return await readFile(filePath, 'utf8');
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error.code === 'ENOENT') {
|
|
189
|
+
return '';
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { appendEvent } from './event-log.js';
|
|
5
|
+
import { resolveStoreDir, withLock } from './store.js';
|
|
6
|
+
import { scheduleSchema } from '../types/index.js';
|
|
7
|
+
const SCHEDULE_FILE = 'schedule.json';
|
|
8
|
+
function resolveGlobalStore(context) {
|
|
9
|
+
return resolveStoreDir({
|
|
10
|
+
cwd: context.cwd,
|
|
11
|
+
homeDir: context.homeDir,
|
|
12
|
+
scope: 'global',
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
async function readScheduleFile(storeDir) {
|
|
16
|
+
const filePath = join(storeDir, SCHEDULE_FILE);
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(filePath, 'utf8');
|
|
19
|
+
return scheduleSchema.array().parse(JSON.parse(raw));
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
if (error.code === 'ENOENT') {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
async function writeScheduleFile(storeDir, schedules) {
|
|
29
|
+
const filePath = join(storeDir, SCHEDULE_FILE);
|
|
30
|
+
await writeFile(filePath, `${JSON.stringify(scheduleSchema.array().parse(schedules), null, 2)}\n`, 'utf8');
|
|
31
|
+
}
|
|
32
|
+
export async function addSchedule(input) {
|
|
33
|
+
const storeDir = resolveGlobalStore(input);
|
|
34
|
+
const lockPath = join(storeDir, SCHEDULE_FILE);
|
|
35
|
+
return withLock(lockPath, async () => {
|
|
36
|
+
const schedules = await readScheduleFile(storeDir);
|
|
37
|
+
if (schedules.some((entry) => entry.name === input.name)) {
|
|
38
|
+
throw new Error(`Schedule ${input.name} already exists`);
|
|
39
|
+
}
|
|
40
|
+
const schedule = scheduleSchema.parse({
|
|
41
|
+
name: input.name,
|
|
42
|
+
cron: input.cron,
|
|
43
|
+
command: input.command,
|
|
44
|
+
enabled: true,
|
|
45
|
+
lastTriggered: null,
|
|
46
|
+
});
|
|
47
|
+
schedules.push(schedule);
|
|
48
|
+
await writeScheduleFile(storeDir, schedules);
|
|
49
|
+
return schedule;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
export async function listSchedules(input) {
|
|
53
|
+
return readScheduleFile(resolveGlobalStore(input));
|
|
54
|
+
}
|
|
55
|
+
export async function removeSchedule(input) {
|
|
56
|
+
const storeDir = resolveGlobalStore(input);
|
|
57
|
+
const lockPath = join(storeDir, SCHEDULE_FILE);
|
|
58
|
+
await withLock(lockPath, async () => {
|
|
59
|
+
const schedules = await readScheduleFile(storeDir);
|
|
60
|
+
const filtered = schedules.filter((entry) => entry.name !== input.name);
|
|
61
|
+
if (filtered.length === schedules.length) {
|
|
62
|
+
throw new Error(`Schedule ${input.name} not found`);
|
|
63
|
+
}
|
|
64
|
+
await writeScheduleFile(storeDir, filtered);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function triggerSchedule(input) {
|
|
68
|
+
const storeDir = resolveGlobalStore(input);
|
|
69
|
+
const lockPath = join(storeDir, SCHEDULE_FILE);
|
|
70
|
+
const schedule = await withLock(lockPath, async () => {
|
|
71
|
+
const schedules = await readScheduleFile(storeDir);
|
|
72
|
+
const target = schedules.find((entry) => entry.name === input.name);
|
|
73
|
+
if (!target) {
|
|
74
|
+
throw new Error(`Schedule ${input.name} not found`);
|
|
75
|
+
}
|
|
76
|
+
target.lastTriggered = new Date().toISOString();
|
|
77
|
+
await writeScheduleFile(storeDir, schedules);
|
|
78
|
+
return target;
|
|
79
|
+
});
|
|
80
|
+
await runShellCommand(schedule.command, input.cwd);
|
|
81
|
+
await appendEvent(storeDir, {
|
|
82
|
+
ts: schedule.lastTriggered ?? new Date().toISOString(),
|
|
83
|
+
type: 'schedule.triggered',
|
|
84
|
+
actor: 'system',
|
|
85
|
+
detail: {
|
|
86
|
+
name: schedule.name,
|
|
87
|
+
command: schedule.command,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async function runShellCommand(command, cwd) {
|
|
92
|
+
await new Promise((resolve, reject) => {
|
|
93
|
+
const child = spawn('sh', ['-lc', command], {
|
|
94
|
+
cwd,
|
|
95
|
+
stdio: 'ignore',
|
|
96
|
+
});
|
|
97
|
+
child.on('error', reject);
|
|
98
|
+
child.on('exit', (code) => {
|
|
99
|
+
if (code === 0) {
|
|
100
|
+
resolve();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
reject(new Error(`Command failed with exit code ${code ?? -1}`));
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { resolveStoreDir } from './store.js';
|
|
5
|
+
const sessionRecordSchema = z.object({
|
|
6
|
+
taskId: z.number().int().positive(),
|
|
7
|
+
runId: z.string().min(1),
|
|
8
|
+
startedAt: z.string().datetime({ offset: true }),
|
|
9
|
+
});
|
|
10
|
+
const sessionsSchema = z.record(z.string(), sessionRecordSchema);
|
|
11
|
+
function resolveSessionsPath(context) {
|
|
12
|
+
return join(resolveStoreDir({
|
|
13
|
+
cwd: context.cwd,
|
|
14
|
+
homeDir: context.homeDir,
|
|
15
|
+
scope: 'project',
|
|
16
|
+
}), 'sessions.json');
|
|
17
|
+
}
|
|
18
|
+
export async function readSessions(context) {
|
|
19
|
+
const filePath = resolveSessionsPath(context);
|
|
20
|
+
try {
|
|
21
|
+
const raw = await readFile(filePath, 'utf8');
|
|
22
|
+
return sessionsSchema.parse(JSON.parse(raw));
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (error.code === 'ENOENT') {
|
|
26
|
+
return {};
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function upsertSession(context, sessionId, record) {
|
|
32
|
+
const filePath = resolveSessionsPath(context);
|
|
33
|
+
const sessions = await readSessions(context);
|
|
34
|
+
sessions[sessionId] = sessionRecordSchema.parse(record);
|
|
35
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
36
|
+
await writeFile(filePath, `${JSON.stringify(sessionsSchema.parse(sessions), null, 2)}\n`, 'utf8');
|
|
37
|
+
}
|
|
38
|
+
export async function getSession(context, sessionId) {
|
|
39
|
+
const sessions = await readSessions(context);
|
|
40
|
+
return sessions[sessionId] ?? null;
|
|
41
|
+
}
|
|
42
|
+
export async function removeSession(context, sessionId) {
|
|
43
|
+
const filePath = resolveSessionsPath(context);
|
|
44
|
+
const sessions = await readSessions(context);
|
|
45
|
+
delete sessions[sessionId];
|
|
46
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
47
|
+
await writeFile(filePath, `${JSON.stringify(sessionsSchema.parse(sessions), null, 2)}\n`, 'utf8');
|
|
48
|
+
}
|