@quantod/qq 0.3.6 → 1.0.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.
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderJavaScriptSdk = renderJavaScriptSdk;
4
+ function renderJavaScriptSdk(host, port, token) {
5
+ return `class BridgeError extends Error { constructor(m) { super(m); this.name = 'BridgeError'; } }
6
+ const QQ = (() => {
7
+ const _u = 'http://${host}:${port}';
8
+ const _h = { 'Authorization': 'Bearer ${token}', 'Content-Type': 'application/json' };
9
+ async function _req(method, path, body) {
10
+ let r;
11
+ try { r = await fetch(_u + path, { method, headers: _h, body: body != null ? JSON.stringify(body) : undefined }); }
12
+ catch { throw new BridgeError('Connection failed — call get_sdk again to refresh credentials'); }
13
+ return r.status === 204 ? undefined : r.json();
14
+ }
15
+ return {
16
+ push: (pipeline, stage, id, payload, opts = {}) => _req('POST', \`/pipelines/\${pipeline}/push\`, { stage, id, payload, ...opts }),
17
+ claim: (pipeline, stage, opts = {}) => _req('POST', \`/pipelines/\${pipeline}/claim\`, { stage, ...opts }),
18
+ release: (pipeline, seq, opts = {}) => _req('POST', \`/pipelines/\${pipeline}/release/\${seq}\`, opts),
19
+ batch_read: (pipeline, stage, opts = {}) => _req('GET', \`/pipelines/\${pipeline}/items?\${new URLSearchParams({ stage, ...opts })}\`),
20
+ status: (pipeline) => _req('GET', \`/pipelines/\${pipeline}/status\`),
21
+ };
22
+ })();`;
23
+ }
24
+ //# sourceMappingURL=javascript.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"javascript.js","sourceRoot":"","sources":["../../src/sdk-templates/javascript.ts"],"names":[],"mappings":";;AAAA,kDAmBC;AAnBD,SAAgB,mBAAmB,CAAC,IAAY,EAAE,IAAY,EAAE,KAAa;IAC3E,OAAO;;uBAEc,IAAI,IAAI,IAAI;0CACO,KAAK;;;;;;;;;;;;;;MAczC,CAAC;AACP,CAAC"}
@@ -0,0 +1,2 @@
1
+ export declare function renderPythonSdk(host: string, port: number, token: string): string;
2
+ //# sourceMappingURL=python.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"python.d.ts","sourceRoot":"","sources":["../../src/sdk-templates/python.ts"],"names":[],"mappings":"AAAA,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CA0BjF"}
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.renderPythonSdk = renderPythonSdk;
4
+ function renderPythonSdk(host, port, token) {
5
+ return `import urllib.request, json as _json, urllib.parse
6
+ class BridgeError(Exception): pass
7
+ _BRIDGE_URL = 'http://${host}:${port}'
8
+ _BRIDGE_HEADERS = {'Authorization': 'Bearer ${token}', 'Content-Type': 'application/json'}
9
+ def _bridge_req(method, path, body=None):
10
+ data = _json.dumps(body).encode() if body is not None else None
11
+ req = urllib.request.Request(_BRIDGE_URL + path, data=data, headers=_BRIDGE_HEADERS, method=method)
12
+ try:
13
+ with urllib.request.urlopen(req) as r:
14
+ return _json.loads(r.read()) if r.status != 204 else None
15
+ except urllib.error.HTTPError as e:
16
+ return _json.loads(e.read())
17
+ except Exception as e:
18
+ raise BridgeError('Connection failed — call get_sdk again to refresh credentials') from e
19
+ def qq_push(pipeline, stage, id=None, payload=None, **opts):
20
+ return _bridge_req('POST', f'/pipelines/{pipeline}/push', {'stage': stage, 'id': id, 'payload': payload, **opts})
21
+ def qq_claim(pipeline, stage, **opts):
22
+ return _bridge_req('POST', f'/pipelines/{pipeline}/claim', {'stage': stage, **opts})
23
+ def qq_release(pipeline, seq, **opts):
24
+ return _bridge_req('POST', f'/pipelines/{pipeline}/release/{seq}', opts)
25
+ def qq_batch_read(pipeline, stage, **opts):
26
+ qs = urllib.parse.urlencode({'stage': stage, **opts})
27
+ return _bridge_req('GET', f'/pipelines/{pipeline}/items?{qs}')
28
+ def qq_status(pipeline):
29
+ return _bridge_req('GET', f'/pipelines/{pipeline}/status')`;
30
+ }
31
+ //# sourceMappingURL=python.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"python.js","sourceRoot":"","sources":["../../src/sdk-templates/python.ts"],"names":[],"mappings":";;AAAA,0CA0BC;AA1BD,SAAgB,eAAe,CAAC,IAAY,EAAE,IAAY,EAAE,KAAa;IACvE,OAAO;;wBAEe,IAAI,IAAI,IAAI;8CACU,KAAK;;;;;;;;;;;;;;;;;;;;;+DAqBY,CAAC;AAChE,CAAC"}
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@quantod/qq",
3
- "version": "0.3.6",
3
+ "version": "1.0.0",
4
4
  "description": "Persistent queue for multi-stage Claude agent pipelines",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
7
- "node": ">=22 <23"
7
+ "node": ">=22"
8
8
  },
9
9
  "main": "./dist/commands.js",
10
10
  "exports": {
@@ -19,19 +19,26 @@
19
19
  "src"
20
20
  ],
21
21
  "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.12.0",
22
23
  "better-sqlite3": "^12.0.0",
24
+ "csv-parse": "^5.5.0",
23
25
  "commander": "^13.1.0",
24
- "js-yaml": "^4.1.0"
26
+ "cors": "^2.8.5",
27
+ "express": "^4.21.0",
28
+ "js-yaml": "^4.1.0",
29
+ "zod": "^3.25.0"
25
30
  },
26
31
  "devDependencies": {
27
32
  "@types/better-sqlite3": "^7.6.0",
33
+ "@types/cors": "^2.8.17",
34
+ "@types/express": "^4.17.21",
28
35
  "@types/js-yaml": "^4.0.9",
29
36
  "@types/node": "^22.0.0",
30
37
  "typescript": "^5.8.3",
31
38
  "vitest": "^3.2.0"
32
39
  },
33
40
  "scripts": {
34
- "build": "tsc --project tsconfig.build.json",
41
+ "build": "tsc --project tsconfig.build.json && mkdir -p dist/resources && cp src/resources/*.md dist/resources/",
35
42
  "test": "vitest run",
36
43
  "lint": "tsc --noEmit"
37
44
  }
package/src/commands.ts CHANGED
@@ -1,16 +1,27 @@
1
1
  import { randomBytes } from 'node:crypto';
2
+ import { readFileSync, unlinkSync } from 'node:fs';
3
+ import { extname } from 'node:path';
2
4
  import type Database from 'better-sqlite3';
3
- import { load } from 'js-yaml';
5
+ import { load, dump } from 'js-yaml';
6
+ import { parse as parseCsv } from 'csv-parse/sync';
4
7
  import { openDb, inTransaction } from './db.js';
5
8
 
6
9
  export * as yaml from 'js-yaml';
7
10
 
8
- function validateYaml(payload: string | undefined, plainText: boolean): void {
9
- if (!payload || plainText) return;
10
- try {
11
- load(payload);
12
- } catch (e) {
13
- throw new QQError(`payload is not valid YAML: ${e instanceof Error ? e.message : String(e)}`);
11
+ export type PayloadFormat = 'yaml' | 'json' | 'text';
12
+
13
+ function processPayload(payload: string | undefined, format: PayloadFormat = 'yaml'): string | undefined {
14
+ if (!payload) return payload;
15
+ switch (format) {
16
+ case 'yaml':
17
+ try { load(payload); }
18
+ catch (e) { throw new QQError(`payload is not valid YAML: ${e instanceof Error ? e.message : String(e)}`); }
19
+ return payload;
20
+ case 'json':
21
+ try { return JSON.stringify(JSON.parse(payload)); }
22
+ catch (e) { throw new QQError(`payload is not valid JSON: ${e instanceof Error ? e.message : String(e)}`); }
23
+ case 'text':
24
+ return payload;
14
25
  }
15
26
  }
16
27
 
@@ -73,7 +84,7 @@ function rowToItem(row: Record<string, unknown>, includePayload: boolean): Item
73
84
 
74
85
  // ── makePipeline ──────────────────────────────────────────────────────────────
75
86
 
76
- export function makePipeline(customName?: string): string {
87
+ export function makePipeline(customName?: string, description?: string): string {
77
88
  if (!customName) {
78
89
  const now = new Date();
79
90
  const mm = String(now.getMonth() + 1).padStart(2, '0');
@@ -86,7 +97,7 @@ export function makePipeline(customName?: string): string {
86
97
  inTransaction(db, () => {
87
98
  const existing = db.prepare('SELECT id FROM pipelines WHERE id = ?').get(name);
88
99
  if (existing) throw new QQError(`pipeline ${name} already exists`);
89
- db.prepare('INSERT INTO pipelines (id, created) VALUES (?, ?)').run(name, Date.now());
100
+ db.prepare('INSERT INTO pipelines (id, created, description) VALUES (?, ?, ?)').run(name, Date.now(), description ?? null);
90
101
  });
91
102
  } finally {
92
103
  db.close();
@@ -95,6 +106,18 @@ export function makePipeline(customName?: string): string {
95
106
  }
96
107
 
97
108
 
109
+ // ── listPipelines ─────────────────────────────────────────────────────────────
110
+
111
+ export function listPipelines(): string[] {
112
+ const db = openDb();
113
+ try {
114
+ const rows = db.prepare('SELECT id FROM pipelines ORDER BY id ASC').all() as { id: string }[];
115
+ return rows.map(r => r.id);
116
+ } finally {
117
+ db.close();
118
+ }
119
+ }
120
+
98
121
  // ── deletePipeline ────────────────────────────────────────────────────────────
99
122
 
100
123
  export function deletePipeline(pipeline: string): void {
@@ -111,11 +134,11 @@ export function deletePipeline(pipeline: string): void {
111
134
  export interface PushOptions {
112
135
  payload?: string;
113
136
  priority?: number;
114
- plainText?: boolean;
137
+ payloadFormat?: PayloadFormat;
115
138
  }
116
139
 
117
- export function push(pipeline: string, stage: string, id?: string, { payload, priority = 0.0, plainText = false }: PushOptions = {}): string {
118
- validateYaml(payload, plainText);
140
+ export function push(pipeline: string, stage = '', id?: string, { payload, priority = 0.0, payloadFormat = 'yaml' }: PushOptions = {}): string {
141
+ const processedPayload = processPayload(payload, payloadFormat);
119
142
  const db = openDb();
120
143
  try {
121
144
  return inTransaction(db, () => {
@@ -126,7 +149,7 @@ export function push(pipeline: string, stage: string, id?: string, { payload, pr
126
149
  const now = Date.now();
127
150
  db.prepare(
128
151
  'INSERT INTO messages (pipeline, id, subqueue, claimed, seq, payload, priority, created, last_modified) VALUES (?, ?, ?, 0, ?, ?, ?, ?, ?)'
129
- ).run(pipeline, actualId, stage, seq, payload ?? null, priority, now, now);
152
+ ).run(pipeline, actualId, stage, seq, processedPayload ?? null, priority, now, now);
130
153
  return actualId;
131
154
  });
132
155
  } finally {
@@ -134,13 +157,97 @@ export function push(pipeline: string, stage: string, id?: string, { payload, pr
134
157
  }
135
158
  }
136
159
 
160
+ // ── loadFile ──────────────────────────────────────────────────────────────────
161
+
162
+ export interface LoadFileOptions {
163
+ stage?: string;
164
+ deleteAfter?: boolean;
165
+ }
166
+
167
+ export interface LoadFileResult {
168
+ id: string;
169
+ status: 'ok' | 'duplicate' | 'error';
170
+ message?: string;
171
+ }
172
+
173
+ function expandEnvVars(p: string): string {
174
+ return p.replace(/\$([A-Z_][A-Z0-9_]*)/gi, (_, name) => process.env[name] ?? '');
175
+ }
176
+
177
+ function parseFileRecords(filePath: string, content: string): Record<string, unknown>[] {
178
+ const ext = extname(filePath).toLowerCase();
179
+ switch (ext) {
180
+ case '.jsonl':
181
+ case '.ndjson':
182
+ return content.split('\n')
183
+ .map(l => l.trim()).filter(Boolean)
184
+ .map((l, i) => {
185
+ try { return JSON.parse(l) as Record<string, unknown>; }
186
+ catch (e) { throw new Error(`JSONL line ${i + 1}: ${e instanceof Error ? e.message : String(e)}`); }
187
+ });
188
+ case '.json': {
189
+ const parsed = JSON.parse(content) as unknown;
190
+ if (!Array.isArray(parsed)) throw new Error('JSON file must contain an array');
191
+ return parsed as Record<string, unknown>[];
192
+ }
193
+ case '.yaml':
194
+ case '.yml': {
195
+ const doc = load(content);
196
+ if (!Array.isArray(doc)) throw new Error('YAML file must contain a sequence');
197
+ return doc as Record<string, unknown>[];
198
+ }
199
+ case '.csv':
200
+ return parseCsv(content, { columns: true, skip_empty_lines: true }) as Record<string, unknown>[];
201
+ default:
202
+ throw new Error(`unsupported file extension: ${ext} — use .jsonl, .json, .yaml, .yml, or .csv`);
203
+ }
204
+ }
205
+
206
+ export function loadFile(
207
+ rawPath: string,
208
+ pipeline: string,
209
+ { stage: defaultStage = '', deleteAfter = false }: LoadFileOptions = {}
210
+ ): LoadFileResult[] {
211
+ const filePath = expandEnvVars(rawPath);
212
+ const content = readFileSync(filePath, 'utf8');
213
+ const records = parseFileRecords(filePath, content);
214
+
215
+ const results: LoadFileResult[] = [];
216
+
217
+ for (const rec of records) {
218
+ const recStage = typeof rec.stage === 'string' ? rec.stage : defaultStage;
219
+ const recId = rec.id !== undefined ? String(rec.id) : undefined;
220
+ const recPriority = rec.priority !== undefined ? Number(rec.priority) : undefined;
221
+ const recPayload = rec.payload !== undefined
222
+ ? (typeof rec.payload === 'string' ? rec.payload : dump(rec.payload))
223
+ : undefined;
224
+
225
+ try {
226
+ const id = push(pipeline, recStage, recId, { payload: recPayload, priority: recPriority, payloadFormat: 'text' });
227
+ results.push({ id, status: 'ok' });
228
+ } catch (e) {
229
+ if (e instanceof QQError && e.message.includes('already in the pipeline')) {
230
+ results.push({ id: recId ?? '(autogen)', status: 'duplicate' });
231
+ } else {
232
+ results.push({ id: recId ?? '(autogen)', status: 'error', message: e instanceof Error ? e.message : String(e) });
233
+ }
234
+ }
235
+ }
236
+
237
+ if (deleteAfter) {
238
+ try { unlinkSync(filePath); } catch { /* best-effort */ }
239
+ }
240
+
241
+ return results;
242
+ }
243
+
137
244
  // ── claim ─────────────────────────────────────────────────────────────────────
138
245
 
139
246
  export interface ClaimOptions {
140
247
  random?: boolean;
141
248
  }
142
249
 
143
- export function claim(pipeline: string, stage: string, id?: string, { random = false }: ClaimOptions = {}): Item | null {
250
+ export function claim(pipeline: string, stage: string, id?: string, { random = false }: ClaimOptions = {}): Item {
144
251
  const db = openDb();
145
252
  try {
146
253
  return inTransaction(db, () => {
@@ -157,7 +264,7 @@ export function claim(pipeline: string, stage: string, id?: string, { random = f
157
264
  row = db.prepare(
158
265
  `SELECT * FROM messages WHERE pipeline = ? AND subqueue LIKE ? AND claimed = 0 ORDER BY ${orderBy} LIMIT 1`
159
266
  ).get(pipeline, toGlob(stage)) as Record<string, unknown> | undefined;
160
- if (!row) return null;
267
+ if (!row) throw new QQError('no items to claim');
161
268
  }
162
269
 
163
270
  const now = Date.now();
@@ -179,11 +286,11 @@ export interface ReleaseOptions {
179
286
  payload?: string;
180
287
  replace?: boolean;
181
288
  priority?: number;
182
- plainText?: boolean;
289
+ payloadFormat?: PayloadFormat;
183
290
  }
184
291
 
185
- export function release(pipeline: string, seq: number, { target, payload, replace, priority, plainText = false }: ReleaseOptions = {}): void {
186
- validateYaml(payload, plainText);
292
+ export function release(pipeline: string, seq: number, { target, payload, replace, priority, payloadFormat = 'yaml' }: ReleaseOptions = {}): void {
293
+ const processedPayload = processPayload(payload, payloadFormat);
187
294
  const db = openDb();
188
295
  try {
189
296
  inTransaction(db, () => {
@@ -197,10 +304,10 @@ export function release(pipeline: string, seq: number, { target, payload, replac
197
304
  const newPriority = priority ?? (row.priority as number) ?? 0.0;
198
305
 
199
306
  let newPayload: string | null;
200
- if (payload !== undefined) {
307
+ if (processedPayload !== undefined) {
201
308
  newPayload = replace
202
- ? payload
203
- : [row.payload as string | null, payload].filter(Boolean).join('\n');
309
+ ? processedPayload
310
+ : [(row.payload as string | null)?.trimEnd() ?? null, processedPayload].filter(Boolean).join('\n');
204
311
  } else {
205
312
  newPayload = (row.payload as string | null) ?? null;
206
313
  }
@@ -271,14 +378,22 @@ export function batchRead(pipeline: string, stage = '*', filters: FilterOptions
271
378
  }
272
379
  }
273
380
 
274
- // ── stats ─────────────────────────────────────────────────────────────────────
381
+ // ── status ────────────────────────────────────────────────────────────────────
275
382
 
276
- export function stats(pipeline = '*'): Record<string, [number, number]> {
383
+ export interface PipelineStatus {
384
+ description: string | null;
385
+ stats: Record<string, [number, number]>;
386
+ }
387
+
388
+ export function status(pipeline: string): PipelineStatus {
277
389
  const db = openDb();
278
390
  try {
391
+ const pRow = db.prepare('SELECT description FROM pipelines WHERE id = ?').get(pipeline) as { description: string | null } | undefined;
392
+ if (!pRow) throw new QQError(`pipeline ${pipeline} not found`);
393
+
279
394
  const rows = db.prepare(
280
- 'SELECT pipeline, subqueue, claimed, COUNT(*) as count FROM messages WHERE pipeline LIKE ? GROUP BY pipeline, subqueue, claimed'
281
- ).all(toGlob(pipeline)) as { pipeline: string; subqueue: string; claimed: number; count: number }[];
395
+ 'SELECT subqueue, claimed, COUNT(*) as count FROM messages WHERE pipeline = ? GROUP BY subqueue, claimed'
396
+ ).all(pipeline) as { subqueue: string; claimed: number; count: number }[];
282
397
 
283
398
  const agg = new Map<string, { total: number; claimed: number }>();
284
399
  const ensure = (key: string) => {
@@ -286,25 +401,29 @@ export function stats(pipeline = '*'): Record<string, [number, number]> {
286
401
  return agg.get(key)!;
287
402
  };
288
403
 
404
+ let pipelineTotal = 0;
405
+ let pipelineClaimed = 0;
406
+
289
407
  for (const row of rows) {
290
- const pEntry = ensure(row.pipeline);
291
- pEntry.total += row.count;
292
- if (row.claimed) pEntry.claimed += row.count;
408
+ pipelineTotal += row.count;
409
+ if (row.claimed) pipelineClaimed += row.count;
293
410
 
411
+ if (row.subqueue === '') continue;
294
412
  const parts = row.subqueue.split('/');
295
413
  for (let i = 0; i < parts.length; i++) {
296
- const key = row.pipeline + '/' + parts.slice(0, i + 1).join('/');
414
+ const key = parts.slice(0, i + 1).join('/');
297
415
  const entry = ensure(key);
298
416
  entry.total += row.count;
299
417
  if (row.claimed) entry.claimed += row.count;
300
418
  }
301
419
  }
302
420
 
303
- const result: Record<string, [number, number]> = {};
421
+ const stats: Record<string, [number, number]> = { '*': [pipelineTotal, pipelineClaimed] };
304
422
  for (const [key, { total, claimed }] of [...agg.entries()].sort(([a], [b]) => a.localeCompare(b))) {
305
- result[key] = [total, claimed];
423
+ stats[key] = [total, claimed];
306
424
  }
307
- return result;
425
+
426
+ return { description: pRow.description, stats };
308
427
  } finally {
309
428
  db.close();
310
429
  }
package/src/db.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import Database from 'better-sqlite3';
2
- import { mkdirSync, readFileSync } from 'node:fs';
2
+ import { mkdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
 
5
5
  const DDL = `
6
6
  CREATE TABLE IF NOT EXISTS pipelines (
7
- id TEXT PRIMARY KEY,
8
- created INTEGER NOT NULL
7
+ id TEXT PRIMARY KEY,
8
+ created INTEGER NOT NULL,
9
+ description TEXT
9
10
  );
11
+
10
12
  CREATE TABLE IF NOT EXISTS messages (
11
13
  id TEXT NOT NULL,
12
14
  pipeline TEXT NOT NULL REFERENCES pipelines(id) ON DELETE CASCADE,
@@ -25,30 +27,22 @@ CREATE INDEX IF NOT EXISTS idx_claim_pri ON messages (pipeline, subqueue, claime
25
27
  `;
26
28
 
27
29
  function dbDir(): string {
28
- const home = process.env.HOME;
29
- if (home?.startsWith('/sessions')) {
30
- try {
31
- const workspace = readFileSync(join(home, '.cowork_workspace_location'), 'utf8').trim();
32
- if (workspace) return join(workspace, '.claude', 'qq');
33
- } catch { /* file absent */ }
34
- throw new Error(
35
- 'Cowork sandboxed environment detected.\n' +
36
- 'Before using qq, write your sandbox workspace path to $HOME/.cowork_workspace_location:\n' +
37
- ' echo "<sandbox workspace path>" > $HOME/.cowork_workspace_location\n' +
38
- 'Find the workspace path in your system prompt\'s shell access path mapping.'
39
- );
40
- }
41
- return join(process.cwd(), '.claude', 'qq');
30
+ return join(process.env.HOME!, '.claude', 'qq');
42
31
  }
43
32
 
44
33
  export function openDb(): Database.Database {
45
34
  const dir = dbDir();
46
35
  mkdirSync(dir, { recursive: true });
47
36
  const db = new Database(join(dir, 'qq.db'));
48
- db.pragma('journal_mode = MEMORY');
37
+ db.pragma('journal_mode = WAL');
49
38
  db.pragma('busy_timeout = 5000');
50
39
  db.pragma('foreign_keys = ON');
51
40
  db.exec(DDL);
41
+ // Migration: add description column if missing (existing DBs pre-date this column)
42
+ const cols = db.prepare("PRAGMA table_info(pipelines)").all() as { name: string }[];
43
+ if (!cols.some(c => c.name === 'description')) {
44
+ db.exec('ALTER TABLE pipelines ADD COLUMN description TEXT');
45
+ }
52
46
  return db;
53
47
  }
54
48
 
package/src/http.ts ADDED
@@ -0,0 +1,133 @@
1
+ import express from 'express';
2
+ import cors from 'cors';
3
+ import { createServer } from 'node:net';
4
+ import { randomBytes } from 'node:crypto';
5
+ import {
6
+ makePipeline, deletePipeline, push, claim, release,
7
+ batchRead, status, unstick, loadFile, QQError,
8
+ } from './commands.js';
9
+
10
+ export interface HttpServer {
11
+ port: number;
12
+ token: string;
13
+ close(): void;
14
+ }
15
+
16
+ function pickPort(): Promise<number> {
17
+ return new Promise((resolve, reject) => {
18
+ const srv = createServer();
19
+ srv.listen(0, () => {
20
+ const addr = srv.address();
21
+ srv.close(() => {
22
+ if (addr && typeof addr === 'object') resolve(addr.port);
23
+ else reject(new Error('failed to get port'));
24
+ });
25
+ });
26
+ });
27
+ }
28
+
29
+ export async function startHttp(fixedPort?: number): Promise<HttpServer> {
30
+ const port = fixedPort ?? await pickPort();
31
+ const token = randomBytes(32).toString('hex');
32
+
33
+ const app = express();
34
+ app.use(cors());
35
+ app.use(express.json({ limit: '50mb' }));
36
+
37
+ // Auth middleware
38
+ app.use((req, res, next) => {
39
+ const auth = req.headers['authorization'];
40
+ if (auth !== `Bearer ${token}`) {
41
+ res.status(401).json({ error: 'Unauthorized' });
42
+ return;
43
+ }
44
+ next();
45
+ });
46
+
47
+ function wrap(res: express.Response, fn: () => unknown) {
48
+ try {
49
+ const result = fn();
50
+ res.json(result ?? { ok: true });
51
+ } catch (e) {
52
+ const msg = e instanceof Error ? e.message : String(e);
53
+ const status = e instanceof QQError ? 400 : 500;
54
+ res.status(status).json({ error: msg });
55
+ }
56
+ }
57
+
58
+ // Routes
59
+ app.post('/pipelines', (req, res) => {
60
+ wrap(res, () => ({ pipeline: makePipeline(req.body?.name, req.body?.description) }));
61
+ });
62
+
63
+ app.delete('/pipelines/:pipeline', (req, res) => {
64
+ wrap(res, () => { deletePipeline(req.params.pipeline); });
65
+ });
66
+
67
+ app.post('/pipelines/:pipeline/push', (req, res) => {
68
+ const { stage, id, payload, priority, payloadFormat } = req.body ?? {};
69
+ wrap(res, () => ({ id: push(req.params.pipeline, stage, id, { payload, priority, payloadFormat }) }));
70
+ });
71
+
72
+ app.post('/pipelines/:pipeline/claim', (req, res) => {
73
+ const { stage = '*', id, random } = req.body ?? {};
74
+ wrap(res, () => claim(req.params.pipeline, stage, id, { random }));
75
+ });
76
+
77
+ app.post('/pipelines/:pipeline/release/:seq', (req, res) => {
78
+ const seq = parseInt(req.params.seq, 10);
79
+ const { target, payload, replace, priority, payloadFormat } = req.body ?? {};
80
+ wrap(res, () => { release(req.params.pipeline, seq, { target, payload, replace, priority, payloadFormat }); });
81
+ });
82
+
83
+ app.get('/pipelines/:pipeline/status', (req, res) => {
84
+ wrap(res, () => status(req.params.pipeline));
85
+ });
86
+
87
+ app.get('/pipelines/:pipeline/items', (req, res) => {
88
+ const { stage = '*', includePayload, claimed, ids, createdAfter, createdBefore, modifiedAfter, limit, offset } = req.query;
89
+ wrap(res, () => batchRead(
90
+ req.params.pipeline,
91
+ String(stage),
92
+ {
93
+ claimed: claimed !== undefined ? String(claimed).toLowerCase() === 'true' : undefined,
94
+ ids: ids ? String(ids).split(',') : undefined,
95
+ created_after: createdAfter ? Number(createdAfter) : undefined,
96
+ created_before: createdBefore ? Number(createdBefore) : undefined,
97
+ modified_after: modifiedAfter ? Number(modifiedAfter) : undefined,
98
+ limit: limit ? Number(limit) : undefined,
99
+ offset: offset ? Number(offset) : undefined,
100
+ },
101
+ String(includePayload).toLowerCase() === 'true',
102
+ ));
103
+ });
104
+
105
+ app.post('/pipelines/:pipeline/load', (req, res) => {
106
+ const { path: filePath, stage, deleteAfter } = req.body ?? {};
107
+ wrap(res, () => {
108
+ const results = loadFile(filePath, req.params.pipeline, { stage, deleteAfter });
109
+ return results.map(r => r.message ? `${r.id}: ${r.status}: ${r.message}` : `${r.id}: ${r.status}`);
110
+ });
111
+ });
112
+
113
+ app.post('/pipelines/:pipeline/unstick', (req, res) => {
114
+ const { stage = '*', ids, createdAfter, createdBefore, modifiedAfter } = req.body ?? {};
115
+ wrap(res, () => ({
116
+ unstuck: unstick(req.params.pipeline, stage, {
117
+ ids: ids ?? undefined,
118
+ created_after: createdAfter ?? undefined,
119
+ created_before: createdBefore ?? undefined,
120
+ modified_after: modifiedAfter ?? undefined,
121
+ }),
122
+ }));
123
+ });
124
+
125
+ const server = app.listen(port, '0.0.0.0');
126
+ await new Promise<void>((resolve) => server.once('listening', resolve));
127
+
128
+ return {
129
+ port,
130
+ token,
131
+ close: () => server.close(),
132
+ };
133
+ }