@quantod/qq 0.3.7 → 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.
- package/dist/commands.d.ts +23 -7
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +134 -30
- package/dist/commands.js.map +1 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +11 -17
- package/dist/db.js.map +1 -1
- package/dist/http.d.ts +7 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +113 -0
- package/dist/http.js.map +1 -0
- package/dist/index.js +52 -29
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +232 -0
- package/dist/mcp.js.map +1 -0
- package/dist/resources/chrome.md +33 -0
- package/dist/resources/guide.md +491 -0
- package/dist/resources/pipeline_design.md +192 -0
- package/dist/sdk-templates/javascript.d.ts +2 -0
- package/dist/sdk-templates/javascript.d.ts.map +1 -0
- package/dist/sdk-templates/javascript.js +24 -0
- package/dist/sdk-templates/javascript.js.map +1 -0
- package/dist/sdk-templates/python.d.ts +2 -0
- package/dist/sdk-templates/python.d.ts.map +1 -0
- package/dist/sdk-templates/python.js +31 -0
- package/dist/sdk-templates/python.js.map +1 -0
- package/package.json +10 -3
- package/src/commands.ts +151 -32
- package/src/db.ts +12 -18
- package/src/http.ts +133 -0
- package/src/index.ts +51 -32
- package/src/mcp.ts +226 -0
- package/src/resources/chrome.md +33 -0
- package/src/resources/guide.md +491 -0
- package/src/resources/pipeline_design.md +192 -0
- package/src/sdk-templates/javascript.ts +20 -0
- package/src/sdk-templates/python.ts +27 -0
|
@@ -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 @@
|
|
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quantod/qq",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Persistent queue for multi-stage Claude agent pipelines",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"engines": {
|
|
@@ -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
|
-
"
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
137
|
+
payloadFormat?: PayloadFormat;
|
|
115
138
|
}
|
|
116
139
|
|
|
117
|
-
export function push(pipeline: string, stage
|
|
118
|
-
|
|
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,
|
|
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
|
|
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)
|
|
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
|
-
|
|
289
|
+
payloadFormat?: PayloadFormat;
|
|
183
290
|
}
|
|
184
291
|
|
|
185
|
-
export function release(pipeline: string, seq: number, { target, payload, replace, priority,
|
|
186
|
-
|
|
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 (
|
|
307
|
+
if (processedPayload !== undefined) {
|
|
201
308
|
newPayload = replace
|
|
202
|
-
?
|
|
203
|
-
: [row.payload as string | null,
|
|
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
|
-
// ──
|
|
381
|
+
// ── status ────────────────────────────────────────────────────────────────────
|
|
275
382
|
|
|
276
|
-
export
|
|
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
|
|
281
|
-
).all(
|
|
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
|
-
|
|
291
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
423
|
+
stats[key] = [total, claimed];
|
|
306
424
|
}
|
|
307
|
-
|
|
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
|
|
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
|
|
8
|
-
created
|
|
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
|
-
|
|
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 =
|
|
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
|
+
}
|