input-kanban 0.0.9 → 0.0.12
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/ENVIRONMENT.md +8 -4
- package/PROJECT_GUIDE.md +11 -9
- package/README.en.md +19 -13
- package/README.md +19 -13
- package/RELEASE_NOTES.md +63 -0
- package/bin/input-kanban.js +43 -40
- package/package.json +2 -2
- package/public/index.html +126 -93
- package/src/orchestrator.js +167 -28
- package/src/scheduler.js +40 -0
- package/src/server.js +8 -5
- package/src/utils.js +4 -2
package/src/orchestrator.js
CHANGED
|
@@ -4,7 +4,7 @@ import fsp from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import {
|
|
7
|
-
DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
|
|
7
|
+
DEFAULT_WORKSPACE, DEFAULT_REPO, RUNS_DIR, ensureDir, nowIso, makeRunId, readJson,
|
|
8
8
|
writeJsonAtomic, fileInfo, readTextMaybe, extractFirstJsonObject, listRunDirs,
|
|
9
9
|
pathForRun, roleDir, safeIdPart, RUNNER
|
|
10
10
|
} from './utils.js';
|
|
@@ -31,6 +31,61 @@ function normalizeSandbox(value, fallback = 'workspace-write') {
|
|
|
31
31
|
function statePath(runDir) { return path.join(runDir, 'run_state.json'); }
|
|
32
32
|
function planPath(runDir) { return path.join(runDir, 'plan.json'); }
|
|
33
33
|
function lockPath(runDir) { return path.join(runDir, RUN_STATE_LOCK_NAME); }
|
|
34
|
+
function workspacePathOf(state) { return path.resolve(state?.workspacePath || state?.repo || DEFAULT_WORKSPACE || DEFAULT_REPO); }
|
|
35
|
+
function workspaceNameOf(state) { return state?.workspaceName || path.basename(workspacePathOf(state)) || workspacePathOf(state); }
|
|
36
|
+
async function detectWorkspaceMetadata(workspacePath) {
|
|
37
|
+
const resolvedWorkspace = path.resolve(workspacePath || DEFAULT_WORKSPACE || DEFAULT_REPO || process.cwd());
|
|
38
|
+
const metadata = {
|
|
39
|
+
path: resolvedWorkspace,
|
|
40
|
+
name: path.basename(resolvedWorkspace) || resolvedWorkspace,
|
|
41
|
+
isGit: false
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
const { stdout: rootStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'rev-parse', '--show-toplevel'], { timeout: 5000 });
|
|
45
|
+
const gitRoot = rootStdout.trim();
|
|
46
|
+
if (gitRoot) {
|
|
47
|
+
metadata.isGit = true;
|
|
48
|
+
metadata.gitRoot = gitRoot;
|
|
49
|
+
try {
|
|
50
|
+
const { stdout: branchStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'branch', '--show-current'], { timeout: 5000 });
|
|
51
|
+
metadata.branch = branchStdout.trim() || 'detached';
|
|
52
|
+
} catch {
|
|
53
|
+
metadata.branch = 'unknown';
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const { stdout: dirtyStdout } = await execFileAsync('git', ['-C', resolvedWorkspace, 'status', '--porcelain'], { timeout: 5000 });
|
|
57
|
+
metadata.dirty = dirtyStdout.trim().length > 0;
|
|
58
|
+
} catch {
|
|
59
|
+
metadata.dirty = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
return metadata;
|
|
64
|
+
}
|
|
65
|
+
async function assertWorkspacePath(workspace) {
|
|
66
|
+
const resolvedWorkspace = path.resolve(workspace || DEFAULT_WORKSPACE || DEFAULT_REPO || process.cwd());
|
|
67
|
+
let stat;
|
|
68
|
+
try { stat = await fsp.stat(resolvedWorkspace); }
|
|
69
|
+
catch { throw userInputError(`workspace does not exist: ${resolvedWorkspace}`); }
|
|
70
|
+
if (!stat.isDirectory()) throw userInputError(`workspace is not a directory: ${resolvedWorkspace}`);
|
|
71
|
+
return resolvedWorkspace;
|
|
72
|
+
}
|
|
73
|
+
function normalizeWorkspaceState(state) {
|
|
74
|
+
const workspacePath = path.resolve(state?.workspacePath || state?.repo || DEFAULT_WORKSPACE || DEFAULT_REPO);
|
|
75
|
+
const workspaceName = state.workspaceName || path.basename(workspacePath) || workspacePath;
|
|
76
|
+
const git = state.git || state.workspace?.git || null;
|
|
77
|
+
state.workspacePath = workspacePath;
|
|
78
|
+
state.workspaceName = workspaceName;
|
|
79
|
+
state.repo = state.repo || workspacePath;
|
|
80
|
+
state.git = git;
|
|
81
|
+
state.workspace = state.workspace || {
|
|
82
|
+
path: workspacePath,
|
|
83
|
+
name: workspaceName,
|
|
84
|
+
git
|
|
85
|
+
};
|
|
86
|
+
if (!state.workspace.git && git) state.workspace.git = git;
|
|
87
|
+
return state;
|
|
88
|
+
}
|
|
34
89
|
|
|
35
90
|
async function isStaleRunLock(lockFile) {
|
|
36
91
|
const info = await fileInfo(lockFile);
|
|
@@ -165,28 +220,29 @@ function deriveRunLabel(label, taskText) {
|
|
|
165
220
|
return truncateDisplayWidth(cleaned, MAX_DERIVED_LABEL_DISPLAY_WIDTH) || 'task';
|
|
166
221
|
}
|
|
167
222
|
|
|
168
|
-
async function assertGitWorkTree(repo) {
|
|
169
|
-
const resolvedRepo = path.resolve(repo || DEFAULT_REPO);
|
|
170
|
-
let stat;
|
|
171
|
-
try { stat = await fsp.stat(resolvedRepo); }
|
|
172
|
-
catch { throw userInputError(`target repository does not exist: ${resolvedRepo}`); }
|
|
173
|
-
if (!stat.isDirectory()) throw userInputError(`target repository is not a directory: ${resolvedRepo}`);
|
|
174
|
-
try {
|
|
175
|
-
const { stdout } = await execFileAsync('git', ['-C', resolvedRepo, 'rev-parse', '--is-inside-work-tree'], { timeout: 5000 });
|
|
176
|
-
if (stdout.trim() === 'true') return resolvedRepo;
|
|
177
|
-
} catch {}
|
|
178
|
-
throw userInputError(`target repository is not a git work tree: ${resolvedRepo}`);
|
|
179
|
-
}
|
|
180
223
|
|
|
181
|
-
export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
182
|
-
const
|
|
224
|
+
export async function createRun({ label = '', taskText = '', workspace = '', repo = DEFAULT_REPO, maxParallel = 3, workerSandbox = 'workspace-write' } = {}) {
|
|
225
|
+
const resolvedWorkspace = await assertWorkspacePath(workspace || repo || DEFAULT_WORKSPACE);
|
|
226
|
+
const workspaceMeta = await detectWorkspaceMetadata(resolvedWorkspace);
|
|
183
227
|
const runLabel = deriveRunLabel(label, taskText);
|
|
184
228
|
const runId = makeRunId(runLabel);
|
|
185
229
|
const runDir = pathForRun(runId);
|
|
186
230
|
await ensureDir(runDir);
|
|
187
231
|
await fsp.writeFile(path.join(runDir, 'task.md'), taskText || '');
|
|
188
232
|
const state = {
|
|
189
|
-
runId,
|
|
233
|
+
runId,
|
|
234
|
+
label: runLabel,
|
|
235
|
+
workspacePath: resolvedWorkspace,
|
|
236
|
+
workspaceName: workspaceMeta.name,
|
|
237
|
+
workspace: {
|
|
238
|
+
path: resolvedWorkspace,
|
|
239
|
+
name: workspaceMeta.name,
|
|
240
|
+
git: workspaceMeta
|
|
241
|
+
},
|
|
242
|
+
git: workspaceMeta,
|
|
243
|
+
repo: resolvedWorkspace,
|
|
244
|
+
maxParallel: Number(maxParallel) || 3,
|
|
245
|
+
workerSandbox: normalizeSandbox(workerSandbox),
|
|
190
246
|
runner: RUNNER,
|
|
191
247
|
status: 'created', createdAt: nowIso(), updatedAt: nowIso(),
|
|
192
248
|
planner: { status: 'pending' }, batches: [], tasks: [], judge: { status: 'pending' }
|
|
@@ -195,23 +251,57 @@ export async function createRun({ label = '', taskText = '', repo = DEFAULT_REPO
|
|
|
195
251
|
return state;
|
|
196
252
|
}
|
|
197
253
|
|
|
198
|
-
|
|
254
|
+
function normalizeWorkspaceFilter(workspace) {
|
|
255
|
+
const value = String(workspace || '').trim();
|
|
256
|
+
if (!value || value === 'all') return '';
|
|
257
|
+
return path.resolve(value);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function runMatchesWorkspace(run, workspaceFilter) {
|
|
261
|
+
if (!workspaceFilter) return true;
|
|
262
|
+
const runWorkspace = path.resolve(run?.workspacePath || run?.repo || '');
|
|
263
|
+
return runWorkspace === workspaceFilter;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function isTerminalRunStatus(status) {
|
|
267
|
+
return ['judged', 'judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(status);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function isFailureRunStatus(status) {
|
|
271
|
+
return ['judge_failed', 'batch_blocked', 'plan_failed', 'plan_empty', 'stopped'].includes(status);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function listRuns({ includeArchived = false, workspace = '' } = {}) {
|
|
275
|
+
const workspaceFilter = normalizeWorkspaceFilter(workspace);
|
|
199
276
|
const dirs = await listRunDirs();
|
|
200
277
|
const rows = [];
|
|
201
278
|
for (const dir of dirs) {
|
|
202
|
-
const s = await
|
|
203
|
-
if (s
|
|
279
|
+
const s = await loadRun(path.basename(dir));
|
|
280
|
+
if (!s || (!includeArchived && s.archived)) continue;
|
|
281
|
+
const summary = summaryOfRun(s);
|
|
282
|
+
if (!runMatchesWorkspace(summary, workspaceFilter)) continue;
|
|
283
|
+
rows.push(summary);
|
|
204
284
|
}
|
|
205
285
|
return rows;
|
|
206
286
|
}
|
|
207
287
|
|
|
208
288
|
export async function loadRun(runId) {
|
|
209
289
|
const state = await readJson(statePath(pathForRun(runId)), null);
|
|
210
|
-
if (state)
|
|
290
|
+
if (state) {
|
|
291
|
+
normalizeWorkspaceState(state);
|
|
292
|
+
if (!state.git || typeof state.git.isGit !== 'boolean') {
|
|
293
|
+
const workspaceMeta = await detectWorkspaceMetadata(workspacePathOf(state));
|
|
294
|
+
state.git = workspaceMeta;
|
|
295
|
+
state.workspace = state.workspace || { path: workspacePathOf(state), name: state.workspaceName, git: workspaceMeta };
|
|
296
|
+
state.workspace.git = workspaceMeta;
|
|
297
|
+
}
|
|
298
|
+
ensureBatchShape(state);
|
|
299
|
+
}
|
|
211
300
|
return state;
|
|
212
301
|
}
|
|
213
302
|
|
|
214
303
|
async function saveRun(state) {
|
|
304
|
+
normalizeWorkspaceState(state);
|
|
215
305
|
ensureBatchShape(state);
|
|
216
306
|
state.updatedAt = nowIso();
|
|
217
307
|
await writeJsonAtomic(statePath(pathForRun(state.runId)), state);
|
|
@@ -319,7 +409,7 @@ export async function startPlanner(runId) {
|
|
|
319
409
|
await fsp.rm(planPath(runDir), { force: true });
|
|
320
410
|
const taskText = await fsp.readFile(path.join(runDir, 'task.md'), 'utf8');
|
|
321
411
|
const prompt = defaultPlannerPrompt(state, taskText);
|
|
322
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: state
|
|
412
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'planner', batchId: 'planner', runStatePath: statePath(runDir), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
|
|
323
413
|
state.status = 'planning';
|
|
324
414
|
state.planner = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir, attempt: (state.plannerAttempts?.length || 0) + 1 };
|
|
325
415
|
await saveRun(state);
|
|
@@ -499,7 +589,7 @@ export async function dispatchRun(runId) {
|
|
|
499
589
|
}
|
|
500
590
|
|
|
501
591
|
function artifactPathForState(state, rel) {
|
|
502
|
-
return path.isAbsolute(rel) ? rel : path.join(state
|
|
592
|
+
return path.isAbsolute(rel) ? rel : path.join(workspacePathOf(state), rel);
|
|
503
593
|
}
|
|
504
594
|
|
|
505
595
|
function workerArtifactInstructions(state, task) {
|
|
@@ -529,7 +619,7 @@ ORCHESTRATOR_BATCH_ID: ${task.batchId || 'batch-1'}
|
|
|
529
619
|
|
|
530
620
|
${task.prompt}${workerArtifactInstructions(state, task)}${upstreamArtifactInstructions(state, task)}
|
|
531
621
|
`;
|
|
532
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, batchId: task.batchId || 'batch-1', runStatePath: statePath(runDir), prompt: fullPrompt, sandbox: task.sandbox || state.workerSandbox || 'workspace-write', cwd: state
|
|
622
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: task.id, batchId: task.batchId || 'batch-1', runStatePath: statePath(runDir), prompt: fullPrompt, sandbox: task.sandbox || state.workerSandbox || 'workspace-write', cwd: workspacePathOf(state), outDir });
|
|
533
623
|
Object.assign(task, { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir });
|
|
534
624
|
}
|
|
535
625
|
|
|
@@ -688,7 +778,7 @@ export async function startJudge(runId) {
|
|
|
688
778
|
const judgeInput = await buildJudgeInput(state);
|
|
689
779
|
await writeJsonAtomic(judgeInputPath, judgeInput);
|
|
690
780
|
const prompt = defaultJudgePrompt(state, judgeInputPath);
|
|
691
|
-
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: state
|
|
781
|
+
const child = await runner.startCodexTask({ runId: state.runId, taskId: 'judge', batchId: 'judge', runStatePath: statePath(pathForRun(runId)), prompt, sandbox: 'read-only', cwd: workspacePathOf(state), outDir });
|
|
692
782
|
state.judge = { status: 'running', pid: child.pid, startedAt: nowIso(), dir: outDir };
|
|
693
783
|
state.status = 'judging';
|
|
694
784
|
await saveRun(state);
|
|
@@ -711,6 +801,50 @@ export async function refreshRun(runId, appClient = null) {
|
|
|
711
801
|
return await loadAndRefreshRun(runId, appClient, { light: false });
|
|
712
802
|
}
|
|
713
803
|
|
|
804
|
+
export async function autoAdvanceRun(runId, { appClient = null, startCreated = false, maxRetries = 1, retryReason = 'auto retry from scheduler' } = {}) {
|
|
805
|
+
let state = await refreshRun(runId, appClient);
|
|
806
|
+
if (!state || state.archived || state.status === 'stopped') return state;
|
|
807
|
+
if (startCreated && state.status === 'created') {
|
|
808
|
+
try { state = await startPlanner(runId); }
|
|
809
|
+
catch (error) { if (!/planner already running/i.test(error.message || '')) throw error; }
|
|
810
|
+
state = await refreshRun(runId, appClient);
|
|
811
|
+
}
|
|
812
|
+
if (!state || state.archived || state.status === 'stopped') return state;
|
|
813
|
+
if (state.status === 'batch_blocked') {
|
|
814
|
+
try { state = await retryRun(runId, { reason: retryReason, maxRetries, auto: true }); }
|
|
815
|
+
catch (error) { state.autoAdvanceError = error.message || String(error); }
|
|
816
|
+
return await refreshRun(runId, appClient) || state;
|
|
817
|
+
}
|
|
818
|
+
if (state.status === 'planned') {
|
|
819
|
+
try { state = await dispatchRun(runId); }
|
|
820
|
+
catch (error) { if (!/all batches completed|current batch is blocked/i.test(error.message || '')) throw error; }
|
|
821
|
+
return await refreshRun(runId, appClient) || state;
|
|
822
|
+
}
|
|
823
|
+
if (state.status === 'batches_completed' && state.judge?.status !== 'running' && state.judge?.status !== 'completed') {
|
|
824
|
+
try { state = await startJudge(runId); }
|
|
825
|
+
catch (error) { if (!/final judge is allowed only after all batches completed/i.test(error.message || '')) throw error; }
|
|
826
|
+
return await refreshRun(runId, appClient) || state;
|
|
827
|
+
}
|
|
828
|
+
return state;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
export async function autoAdvanceActiveRuns({ appClient = null, startCreated = false, maxRetries = 1, retryReason = 'auto retry from scheduler' } = {}) {
|
|
832
|
+
const dirs = await listRunDirs();
|
|
833
|
+
const results = [];
|
|
834
|
+
for (const dir of dirs) {
|
|
835
|
+
const runId = path.basename(dir);
|
|
836
|
+
try {
|
|
837
|
+
const initial = await loadRun(runId);
|
|
838
|
+
if (!initial || initial.archived || ['judged', 'judge_failed', 'plan_failed', 'plan_empty', 'stopped'].includes(initial.status)) continue;
|
|
839
|
+
const state = await autoAdvanceRun(runId, { appClient, startCreated, maxRetries, retryReason });
|
|
840
|
+
if (state) results.push({ runId, status: state.status, ok: true });
|
|
841
|
+
} catch (error) {
|
|
842
|
+
results.push({ runId, ok: false, error: error.message || String(error) });
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
return results;
|
|
846
|
+
}
|
|
847
|
+
|
|
714
848
|
async function loadAndRefreshRun(runId, appClient = null, { light = false } = {}) {
|
|
715
849
|
return await withRunStateLock(runId, async () => {
|
|
716
850
|
const state = await loadRun(runId);
|
|
@@ -796,7 +930,7 @@ async function refreshTask(state, task) {
|
|
|
796
930
|
await attachTmuxMetadata(task, dir);
|
|
797
931
|
delete task.attentionHint;
|
|
798
932
|
task.artifacts = [];
|
|
799
|
-
for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(state
|
|
933
|
+
for (const rel of task.expectedArtifacts || []) task.artifacts.push({ path: rel, ...(await fileInfo(path.isAbsolute(rel) ? rel : path.join(workspacePathOf(state), rel))) });
|
|
800
934
|
const batch = (state.batches || []).find(b => b.id === task.batchId);
|
|
801
935
|
if (batch) {
|
|
802
936
|
const bt = batch.tasks.find(t => t.id === task.id);
|
|
@@ -1021,7 +1155,10 @@ async function buildJudgeInput(state) {
|
|
|
1021
1155
|
run: {
|
|
1022
1156
|
runId: state.runId,
|
|
1023
1157
|
label: state.label,
|
|
1024
|
-
repo: state
|
|
1158
|
+
repo: workspacePathOf(state),
|
|
1159
|
+
workspacePath: workspacePathOf(state),
|
|
1160
|
+
workspaceName: state.workspaceName || path.basename(workspacePathOf(state)) || workspacePathOf(state),
|
|
1161
|
+
git: state.git || state.workspace?.git || null,
|
|
1025
1162
|
status: state.status,
|
|
1026
1163
|
runner: state.runner || RUNNER,
|
|
1027
1164
|
createdAt: state.createdAt,
|
|
@@ -1065,7 +1202,7 @@ function ensureBatchShape(state) {
|
|
|
1065
1202
|
}
|
|
1066
1203
|
|
|
1067
1204
|
async function enrichFromAppServer(state, appClient) {
|
|
1068
|
-
const res = await appClient.listThreads({ cwd: state
|
|
1205
|
+
const res = await appClient.listThreads({ cwd: workspacePathOf(state), limit: 100 });
|
|
1069
1206
|
const threads = res?.data || [];
|
|
1070
1207
|
const all = [{ id: 'planner', target: state.planner }, ...(state.tasks || []).map(t => ({ id: t.id, target: t })), { id: 'judge', target: state.judge }];
|
|
1071
1208
|
for (const item of all) {
|
|
@@ -1089,7 +1226,9 @@ function runDurationEndOfState(s) {
|
|
|
1089
1226
|
|
|
1090
1227
|
export function summaryOfRun(s) {
|
|
1091
1228
|
const tasks = s.tasks || [];
|
|
1092
|
-
|
|
1229
|
+
const workspacePath = s.workspacePath || s.repo || '';
|
|
1230
|
+
const git = s.git || s.workspace?.git || null;
|
|
1231
|
+
return { runId: s.runId, label: s.label, repo: s.repo || workspacePath, workspacePath, workspaceName: s.workspaceName || path.basename(workspacePath || ''), git, status: s.status, runner: s.runner || RUNNER, workerSandbox: s.workerSandbox || 'workspace-write', archived: !!s.archived, createdAt: s.createdAt, updatedAt: s.updatedAt, durationEnd: runDurationEndOfState(s), total: tasks.length, completed: tasks.filter(t => t.status === 'completed').length, failed: tasks.filter(t => ['failed','unknown'].includes(t.status)).length, running: tasks.filter(t => t.status === 'running').length, batches: (s.batches || []).map(b => ({ id: b.id, name: b.name, status: b.status, total: b.tasks?.length || 0, completed: (b.tasks || []).filter(t => t.status === 'completed').length })) };
|
|
1093
1232
|
}
|
|
1094
1233
|
|
|
1095
1234
|
export async function readRunTaskText(runId) {
|
package/src/scheduler.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { autoAdvanceActiveRuns } from './orchestrator.js';
|
|
2
|
+
|
|
3
|
+
export function startAutoScheduler({ appClient = null, pollMs = Number(process.env.KANBAN_AUTO_POLL_MS || 3000), maxRetries = Number(process.env.KANBAN_AUTO_MAX_RETRIES || 1), startCreated = false, log = false } = {}) {
|
|
4
|
+
const intervalMs = Math.max(500, Number(pollMs) || 3000);
|
|
5
|
+
let stopped = false;
|
|
6
|
+
let running = false;
|
|
7
|
+
let timer = null;
|
|
8
|
+
|
|
9
|
+
const tick = async () => {
|
|
10
|
+
if (stopped || running) return;
|
|
11
|
+
running = true;
|
|
12
|
+
try {
|
|
13
|
+
const results = await autoAdvanceActiveRuns({ appClient, startCreated, maxRetries, retryReason: 'auto retry from server scheduler' });
|
|
14
|
+
if (log) {
|
|
15
|
+
for (const result of results) {
|
|
16
|
+
if (result.ok === false) console.warn(`[scheduler] ${result.runId}: ${result.error}`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (log) console.warn(`[scheduler] ${error.message || String(error)}`);
|
|
21
|
+
} finally {
|
|
22
|
+
running = false;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
timer = setInterval(() => { tick(); }, intervalMs);
|
|
27
|
+
timer.unref?.();
|
|
28
|
+
tick();
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
get running() { return running; },
|
|
32
|
+
get stopped() { return stopped; },
|
|
33
|
+
async tick() { await tick(); },
|
|
34
|
+
stop() {
|
|
35
|
+
stopped = true;
|
|
36
|
+
if (timer) clearInterval(timer);
|
|
37
|
+
timer = null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/server.js
CHANGED
|
@@ -3,8 +3,9 @@ import fsp from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { CodexAppServerClient } from './appServerClient.js';
|
|
6
|
-
import { APP_ROOT, DEFAULT_REPO, PACKAGE_VERSION, RUNNER, RUNS_DIR } from './utils.js';
|
|
6
|
+
import { APP_ROOT, DEFAULT_WORKSPACE, DEFAULT_REPO, PACKAGE_VERSION, RUNNER, RUNS_DIR } from './utils.js';
|
|
7
7
|
import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun, renameRun, retryRun } from './orchestrator.js';
|
|
8
|
+
import { startAutoScheduler } from './scheduler.js';
|
|
8
9
|
|
|
9
10
|
const PUBLIC_DIR = path.join(APP_ROOT, 'public');
|
|
10
11
|
|
|
@@ -42,10 +43,10 @@ async function handleApi(req, res, url, appClient) {
|
|
|
42
43
|
const parts = url.pathname.split('/').filter(Boolean);
|
|
43
44
|
try {
|
|
44
45
|
if (req.method === 'GET' && url.pathname === '/api/health') {
|
|
45
|
-
return send(res, 200, { ok: true, version: PACKAGE_VERSION, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultRepo: DEFAULT_REPO, runner: RUNNER });
|
|
46
|
+
return send(res, 200, { ok: true, version: PACKAGE_VERSION, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultWorkspace: DEFAULT_WORKSPACE, defaultRepo: DEFAULT_REPO, runner: RUNNER });
|
|
46
47
|
}
|
|
47
48
|
if (parts[1] === 'runs' && parts.length === 2) {
|
|
48
|
-
if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1' }) });
|
|
49
|
+
if (req.method === 'GET') return send(res, 200, { runs: await listRuns({ includeArchived: url.searchParams.get('includeArchived') === '1', workspace: url.searchParams.get('workspace') || '' }) });
|
|
49
50
|
if (req.method === 'POST') {
|
|
50
51
|
const body = await readBody(req);
|
|
51
52
|
return send(res, 201, await createRun(body));
|
|
@@ -98,17 +99,19 @@ export function createHttpServer({ appClient = new CodexAppServerClient() } = {}
|
|
|
98
99
|
});
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
export async function startServer({ host = process.env.HOST || '127.0.0.1', port = Number(process.env.PORT || 8787), log = true } = {}) {
|
|
102
|
+
export async function startServer({ host = process.env.HOST || '127.0.0.1', port = Number(process.env.PORT || 8787), log = true, scheduler = true } = {}) {
|
|
102
103
|
const appClient = new CodexAppServerClient();
|
|
103
104
|
const server = createHttpServer({ appClient });
|
|
105
|
+
const autoScheduler = scheduler ? startAutoScheduler({ appClient, log }) : null;
|
|
104
106
|
await new Promise(resolve => server.listen(port, host, resolve));
|
|
105
107
|
const url = `http://${host}:${port}`;
|
|
106
108
|
if (log) console.log(`input-kanban listening on ${url}`);
|
|
107
109
|
const stop = async () => {
|
|
110
|
+
autoScheduler?.stop();
|
|
108
111
|
appClient.stop();
|
|
109
112
|
await new Promise(resolve => server.close(resolve));
|
|
110
113
|
};
|
|
111
|
-
return { server, appClient, host, port, url, version: PACKAGE_VERSION, defaultRepo: DEFAULT_REPO, runsDir: RUNS_DIR, runner: RUNNER, stop };
|
|
114
|
+
return { server, appClient, autoScheduler, host, port, url, version: PACKAGE_VERSION, defaultWorkspace: DEFAULT_WORKSPACE, defaultRepo: DEFAULT_REPO, runsDir: RUNS_DIR, runner: RUNNER, scheduler: !!autoScheduler, stop };
|
|
112
115
|
}
|
|
113
116
|
|
|
114
117
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
package/src/utils.js
CHANGED
|
@@ -3,13 +3,15 @@ import fsp from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
|
|
7
8
|
const require = createRequire(import.meta.url);
|
|
8
9
|
const { version: PACKAGE_VERSION } = require('../package.json');
|
|
9
10
|
|
|
10
|
-
export const APP_ROOT = path.resolve(path.dirname(
|
|
11
|
+
export const APP_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
11
12
|
export { PACKAGE_VERSION };
|
|
12
|
-
export const
|
|
13
|
+
export const DEFAULT_WORKSPACE = path.resolve(process.env.KANBAN_DEFAULT_WORKSPACE || process.env.KANBAN_DEFAULT_REPO || process.cwd());
|
|
14
|
+
export const DEFAULT_REPO = DEFAULT_WORKSPACE;
|
|
13
15
|
export const RUNS_DIR = path.resolve(process.env.KANBAN_RUNS_DIR || path.join(process.env.HOME || APP_ROOT, '.input-kanban', 'runs'));
|
|
14
16
|
export const CODEX_BIN = process.env.KANBAN_CODEX_BIN || 'codex';
|
|
15
17
|
export const VALID_RUNNERS = ['headless', 'tmux'];
|