input-kanban 0.0.1

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/src/server.js ADDED
@@ -0,0 +1,111 @@
1
+ import http from 'node:http';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CodexAppServerClient } from './appServerClient.js';
6
+ import { APP_ROOT, DEFAULT_REPO, RUNS_DIR } from './utils.js';
7
+ import { createRun, listRuns, startPlanner, dispatchRun, startJudge, refreshRun, readRunFile, readRunTaskText, markTaskCompleted, stopRun, archiveRun } from './orchestrator.js';
8
+
9
+ const PUBLIC_DIR = path.join(APP_ROOT, 'public');
10
+
11
+ function send(res, status, body, type = 'application/json') {
12
+ const data = type === 'application/json' ? JSON.stringify(body, null, 2) : body;
13
+ res.writeHead(status, { 'Content-Type': `${type}; charset=utf-8` });
14
+ res.end(data);
15
+ }
16
+
17
+ async function readBody(req) {
18
+ const chunks = [];
19
+ for await (const c of req) chunks.push(c);
20
+ const text = Buffer.concat(chunks).toString('utf8');
21
+ if (!text) return {};
22
+ try { return JSON.parse(text); } catch { return { text }; }
23
+ }
24
+
25
+ function notFound(res) { send(res, 404, { error: 'not found' }); }
26
+ function methodNotAllowed(res) { send(res, 405, { error: 'method not allowed' }); }
27
+
28
+ async function serveStatic(req, res, pathname) {
29
+ let file = pathname === '/' ? path.join(PUBLIC_DIR, 'index.html') : path.join(PUBLIC_DIR, pathname.replace(/^\/+/, ''));
30
+ file = path.resolve(file);
31
+ if (!file.startsWith(PUBLIC_DIR)) return notFound(res);
32
+ try {
33
+ const data = await fsp.readFile(file);
34
+ const ext = path.extname(file);
35
+ const type = ext === '.html' ? 'text/html' : ext === '.js' ? 'text/javascript' : ext === '.css' ? 'text/css' : 'text/plain';
36
+ res.writeHead(200, { 'Content-Type': `${type}; charset=utf-8` });
37
+ res.end(data);
38
+ } catch { notFound(res); }
39
+ }
40
+
41
+ async function handleApi(req, res, url, appClient) {
42
+ const parts = url.pathname.split('/').filter(Boolean);
43
+ try {
44
+ if (req.method === 'GET' && url.pathname === '/api/health') {
45
+ return send(res, 200, { ok: true, appRoot: APP_ROOT, runsDir: RUNS_DIR, defaultRepo: DEFAULT_REPO });
46
+ }
47
+ 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 === 'POST') {
50
+ const body = await readBody(req);
51
+ return send(res, 201, await createRun(body));
52
+ }
53
+ return methodNotAllowed(res);
54
+ }
55
+ if (parts[1] === 'runs' && parts.length >= 3) {
56
+ const runId = parts[2];
57
+ if (parts.length === 4 && parts[3] === 'status' && req.method === 'GET') return send(res, 200, await refreshRun(runId, appClient));
58
+ if (parts.length === 4 && parts[3] === 'plan' && req.method === 'POST') return send(res, 202, await startPlanner(runId));
59
+ if (parts.length === 4 && parts[3] === 'dispatch' && req.method === 'POST') return send(res, 202, await dispatchRun(runId));
60
+ if (parts.length === 4 && parts[3] === 'judge' && req.method === 'POST') return send(res, 202, await startJudge(runId));
61
+ if (parts.length === 4 && parts[3] === 'stop' && req.method === 'POST') {
62
+ const body = await readBody(req);
63
+ return send(res, 200, await stopRun(runId, body));
64
+ }
65
+ if (parts.length === 4 && parts[3] === 'archive' && req.method === 'POST') {
66
+ const body = await readBody(req);
67
+ return send(res, 200, await archiveRun(runId, body));
68
+ }
69
+ if (parts.length === 4 && parts[3] === 'task-text' && req.method === 'GET') return send(res, 200, await readRunTaskText(runId), 'text/plain');
70
+ if (parts.length === 6 && parts[3] === 'tasks' && parts[5] === 'file' && req.method === 'GET') {
71
+ const text = await readRunFile(runId, parts[4], url.searchParams.get('name') || 'last_message.md');
72
+ return send(res, 200, text, 'text/plain');
73
+ }
74
+ if (parts.length === 6 && parts[3] === 'tasks' && parts[5] === 'mark-completed' && req.method === 'POST') {
75
+ const body = await readBody(req);
76
+ return send(res, 200, await markTaskCompleted(runId, parts[4], body));
77
+ }
78
+ }
79
+ notFound(res);
80
+ } catch (e) {
81
+ send(res, 500, { error: e.message });
82
+ }
83
+ }
84
+
85
+ export function createHttpServer({ appClient = new CodexAppServerClient() } = {}) {
86
+ return http.createServer(async (req, res) => {
87
+ const url = new URL(req.url, `http://${req.headers.host || '127.0.0.1'}`);
88
+ if (url.pathname.startsWith('/api/')) return await handleApi(req, res, url, appClient);
89
+ return await serveStatic(req, res, url.pathname);
90
+ });
91
+ }
92
+
93
+ export async function startServer({ host = process.env.HOST || '127.0.0.1', port = Number(process.env.PORT || 8787), log = true } = {}) {
94
+ const appClient = new CodexAppServerClient();
95
+ const server = createHttpServer({ appClient });
96
+ await new Promise(resolve => server.listen(port, host, resolve));
97
+ const url = `http://${host}:${port}`;
98
+ if (log) console.log(`input-kanban listening on ${url}`);
99
+ const stop = async () => {
100
+ appClient.stop();
101
+ await new Promise(resolve => server.close(resolve));
102
+ };
103
+ return { server, appClient, host, port, url, defaultRepo: DEFAULT_REPO, runsDir: RUNS_DIR, stop };
104
+ }
105
+
106
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
107
+ const instance = await startServer();
108
+ const shutdown = () => { instance.stop().finally(() => process.exit(0)); };
109
+ process.on('SIGINT', shutdown);
110
+ process.on('SIGTERM', shutdown);
111
+ }
package/src/utils.js ADDED
@@ -0,0 +1,78 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+
6
+ export const APP_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..');
7
+ export const DEFAULT_REPO = path.resolve(process.env.KANBAN_DEFAULT_REPO || process.cwd());
8
+ export const RUNS_DIR = path.resolve(process.env.KANBAN_RUNS_DIR || path.join(process.env.HOME || APP_ROOT, '.input-kanban', 'runs'));
9
+ export const CODEX_BIN = process.env.KANBAN_CODEX_BIN || 'codex';
10
+
11
+ export async function ensureDir(dir) { await fsp.mkdir(dir, { recursive: true }); }
12
+ export function nowIso() { return new Date().toISOString(); }
13
+ export function safeIdPart(s) { return String(s || '').replace(/[^a-zA-Z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80) || 'run'; }
14
+ export function makeRunId(label='run') {
15
+ const ts = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d+Z$/, 'Z');
16
+ return `run_${ts}_${safeIdPart(label)}_${crypto.randomBytes(3).toString('hex')}`;
17
+ }
18
+ export async function readJson(file, fallback=null) {
19
+ try { return JSON.parse(await fsp.readFile(file, 'utf8')); } catch { return fallback; }
20
+ }
21
+ export async function writeJsonAtomic(file, data) {
22
+ await ensureDir(path.dirname(file));
23
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
24
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2));
25
+ await fsp.rename(tmp, file);
26
+ }
27
+ export async function fileInfo(file) {
28
+ try { const st = await fsp.stat(file); return { exists: true, size: st.size, mtimeMs: st.mtimeMs, mtime: st.mtime.toISOString() }; }
29
+ catch { return { exists: false }; }
30
+ }
31
+ export async function readTextMaybe(file, maxBytes=200000) {
32
+ try {
33
+ const st = await fsp.stat(file);
34
+ const start = Math.max(0, st.size - maxBytes);
35
+ const fh = await fsp.open(file, 'r');
36
+ try {
37
+ const buf = Buffer.alloc(st.size - start);
38
+ await fh.read(buf, 0, buf.length, start);
39
+ return buf.toString('utf8');
40
+ } finally { await fh.close(); }
41
+ } catch { return ''; }
42
+ }
43
+ export function extractFirstJsonObject(text) {
44
+ if (!text) return null;
45
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
46
+ const candidates = [];
47
+ if (fenced) candidates.push(fenced[1]);
48
+ candidates.push(text);
49
+ for (const c of candidates) {
50
+ const start = c.indexOf('{');
51
+ if (start < 0) continue;
52
+ let depth = 0, inStr = false, esc = false;
53
+ for (let i = start; i < c.length; i++) {
54
+ const ch = c[i];
55
+ if (inStr) { if (esc) esc = false; else if (ch === '\\') esc = true; else if (ch === '"') inStr = false; continue; }
56
+ if (ch === '"') inStr = true;
57
+ else if (ch === '{') depth++;
58
+ else if (ch === '}') { depth--; if (depth === 0) { try { return JSON.parse(c.slice(start, i + 1)); } catch {} } }
59
+ }
60
+ }
61
+ return null;
62
+ }
63
+ export async function listRunDirs() {
64
+ await ensureDir(RUNS_DIR);
65
+ const entries = await fsp.readdir(RUNS_DIR, { withFileTypes: true });
66
+ return entries.filter(e => e.isDirectory()).map(e => path.join(RUNS_DIR, e.name)).sort().reverse();
67
+ }
68
+ export function pathForRun(runId) { return path.join(RUNS_DIR, safeIdPart(runId)); }
69
+ export function roleDir(runDir, role, taskId=null) {
70
+ if (role === 'worker') return path.join(runDir, 'workers', safeIdPart(taskId));
71
+ return path.join(runDir, role);
72
+ }
73
+ export async function appendFileStream(stream, file) {
74
+ await ensureDir(path.dirname(file));
75
+ const ws = fs.createWriteStream(file, { flags: 'a' });
76
+ stream.pipe(ws);
77
+ return ws;
78
+ }