@logicseek/hello-cc 1.0.1 → 1.0.2
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/CHANGELOG.md +105 -0
- package/README.md +4 -0
- package/README.zh-CN.md +4 -0
- package/docs/README.md +9 -0
- package/docs/README.zh-CN.md +9 -0
- package/docs/commands.md +53 -0
- package/docs/commands.zh-CN.md +48 -0
- package/docs/dsh.md +137 -0
- package/docs/dsh.zh-CN.md +139 -0
- package/docs/mcp.md +9 -0
- package/docs/mcp.zh-CN.md +23 -0
- package/docs/native.md +295 -0
- package/docs/native.zh-CN.md +253 -0
- package/docs/web-handoff.zh-CN.md +53 -0
- package/lib/cli/app.mjs +25 -6
- package/lib/cli/commands/doctor.mjs +17 -2
- package/lib/cli/commands/dsh.mjs +79 -0
- package/lib/cli/commands/hook.mjs +241 -90
- package/lib/cli/commands/lock.mjs +34 -19
- package/lib/cli/commands/mcp.mjs +28 -0
- package/lib/cli/commands/native.mjs +130 -0
- package/lib/cli/commands/run.mjs +66 -21
- package/lib/cli/commands/task.mjs +6 -3
- package/lib/cli-args.mjs +4 -0
- package/lib/coordination-state.mjs +15 -1
- package/lib/core/coordination/event-retention.mjs +14 -2
- package/lib/core/coordination/gc-plan.mjs +3 -13
- package/lib/core/coordination/locks.mjs +45 -0
- package/lib/core/coordination/results.mjs +79 -0
- package/lib/core/peers/bindings.mjs +9 -3
- package/lib/diagnostics/codex.mjs +121 -0
- package/lib/integrations/codex-interactions.mjs +114 -0
- package/lib/integrations/dsh-collaboration.mjs +170 -0
- package/lib/integrations/dsh-cordis.d.ts +19 -0
- package/lib/integrations/dsh-cordis.mjs +177 -0
- package/lib/integrations/dsh.bundle.yml +7 -0
- package/lib/integrations/dsh.mjs +287 -0
- package/lib/integrations/native/claude.mjs +451 -0
- package/lib/integrations/native/codex.mjs +242 -0
- package/lib/integrations/native/dsh-acp.mjs +343 -0
- package/lib/integrations/native/index.mjs +19 -0
- package/lib/integrations/native/interactions.mjs +53 -0
- package/lib/integrations/native/jsonrpc.mjs +185 -0
- package/lib/integrations/peers/identity.mjs +36 -10
- package/lib/integrations/peers/processes.mjs +22 -8
- package/lib/integrations/providers.mjs +6 -2
- package/lib/mcp/scope.mjs +104 -0
- package/lib/mcp/stdio.mjs +80 -0
- package/lib/mcp/tools.mjs +197 -0
- package/lib/process/identity.mjs +3 -1
- package/lib/release/release-notes.mjs +9 -0
- package/lib/runtime/native/client.mjs +38 -0
- package/lib/runtime/native/service.mjs +511 -0
- package/lib/runtime/native/store.mjs +121 -0
- package/lib/ui/help.mjs +5 -2
- package/lib/web/adoption-state.mjs +55 -0
- package/lib/web/auto-attach.mjs +18 -2
- package/lib/web/codex-app-server.mjs +557 -0
- package/lib/web/codex-sessions.mjs +287 -0
- package/lib/web/control-lease.mjs +204 -0
- package/lib/web/http-routes.mjs +197 -15
- package/lib/web/http.mjs +5 -4
- package/lib/web/native-sessions.mjs +286 -0
- package/lib/web/project-contexts.mjs +2 -1
- package/lib/web/pty-sessions.mjs +60 -33
- package/lib/web/runtime-main.mjs +98 -6
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/task-results.mjs +76 -0
- package/lib/web/tmux-sessions.mjs +92 -11
- package/lib/web/tmux-stream.mjs +2 -2
- package/lib/web/ui-codex.mjs +550 -0
- package/lib/web/ui-command-palette.mjs +119 -0
- package/lib/web/ui-handoff.mjs +102 -0
- package/lib/web/ui-history.mjs +118 -0
- package/lib/web/ui-i18n.mjs +841 -0
- package/lib/web/ui-interactions.mjs +78 -0
- package/lib/web/ui-login.mjs +101 -0
- package/lib/web/ui-native.mjs +165 -0
- package/lib/web/ui-preferences.mjs +278 -0
- package/lib/web/ui-review.mjs +132 -0
- package/lib/web/ui-template.mjs +1106 -576
- package/lib/web/ui-terminal-find.mjs +98 -0
- package/lib/web/ui-workbench.mjs +94 -0
- package/lib/web/ui-workspace.mjs +222 -0
- package/package.json +11 -3
- package/scripts/check-free-identifiers.mjs +67 -8
- package/scripts/codex-diagnostics-installed-acceptance.mjs +72 -0
- package/scripts/dsh-cordis-acceptance.mjs +200 -0
- package/scripts/dsh-installed-acceptance.mjs +247 -0
- package/scripts/dsh-official-acceptance.mjs +180 -0
- package/scripts/dsh-web-startup-acceptance.mjs +87 -0
- package/scripts/github-release.mjs +3 -1
- package/scripts/native-live-acceptance.mjs +369 -0
- package/scripts/regression.mjs +243 -89
- package/scripts/web-handoff-installed-acceptance.mjs +98 -0
- package/scripts/web-native-interaction-acceptance.mjs +204 -0
- package/scripts/web-native-model-acceptance.mjs +138 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
// Lock CLI commands extracted from bin/hcc.mjs.
|
|
2
2
|
// Factory pattern with injected deps.
|
|
3
3
|
|
|
4
|
+
import { readTx } from '../../db/schema.mjs';
|
|
5
|
+
import {
|
|
6
|
+
findRequestedLock,
|
|
7
|
+
upsertRequestedLock,
|
|
8
|
+
visibleLocksWithEvidence
|
|
9
|
+
} from '../../core/coordination/locks.mjs';
|
|
10
|
+
|
|
4
11
|
export function createLockCommands(deps) {
|
|
5
12
|
const {
|
|
6
13
|
connect,
|
|
@@ -109,21 +116,11 @@ export function createLockCommands(deps) {
|
|
|
109
116
|
}
|
|
110
117
|
const existing = subject.locks.find((row) => row.resource === requested.resource) || null;
|
|
111
118
|
const expiresAt = leaseDeadline(t, ttl);
|
|
112
|
-
db
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
ON CONFLICT(resource) DO UPDATE SET
|
|
116
|
-
base_resource = excluded.base_resource,
|
|
117
|
-
scope = excluded.scope,
|
|
118
|
-
owner = excluded.owner,
|
|
119
|
-
task_id = excluded.task_id,
|
|
120
|
-
reason = excluded.reason,
|
|
121
|
-
expires_at = excluded.expires_at,
|
|
122
|
-
created_at = excluded.created_at,
|
|
123
|
-
ttl_sec = excluded.ttl_sec
|
|
124
|
-
`).run(requested.resource, requested.base_resource, requested.scope, peer, taskId, reason, expiresAt, t, ttl);
|
|
119
|
+
const acquired = upsertRequestedLock(db, requested, {
|
|
120
|
+
owner: peer, task_id: taskId, reason, expires_at: expiresAt, created_at: t, ttl_sec: ttl
|
|
121
|
+
});
|
|
125
122
|
addEvent(db, 'lock.acquired', peer, taskId, { resource: requested.base_resource, lock_resource: requested.resource, scope: requested.scope, ttl, previous_owner: existing ? existing.owner : null });
|
|
126
|
-
return { lock:
|
|
123
|
+
return { lock: acquired };
|
|
127
124
|
}
|
|
128
125
|
});
|
|
129
126
|
if (outcome.error) throw outcome.error;
|
|
@@ -143,7 +140,7 @@ export function createLockCommands(deps) {
|
|
|
143
140
|
const db = connect(ctx);
|
|
144
141
|
touchCurrentPeer(db, ctx, identity, null, 'shell');
|
|
145
142
|
const result = tx(db, () => {
|
|
146
|
-
const existing = db
|
|
143
|
+
const existing = findRequestedLock(db, requested);
|
|
147
144
|
if (!existing) return { released: false, ...requested };
|
|
148
145
|
if (existing.owner !== peer && !opts.force) {
|
|
149
146
|
throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
|
@@ -166,7 +163,7 @@ export function createLockCommands(deps) {
|
|
|
166
163
|
const db = connect(ctx);
|
|
167
164
|
touchCurrentPeer(db, ctx, identity, 'working', 'shell');
|
|
168
165
|
const lock = tx(db, () => {
|
|
169
|
-
const existing = db
|
|
166
|
+
const existing = findRequestedLock(db, requested);
|
|
170
167
|
if (!existing) throw new CliError('NOT_FOUND', `No lock for ${lockLabel(requested)}`);
|
|
171
168
|
if (existing.owner !== peer) throw new CliError('LOCK_OWNED', `Lock is owned by ${existing.owner}`, { owner: existing.owner });
|
|
172
169
|
db.prepare('UPDATE locks SET expires_at = ?, ttl_sec = ? WHERE resource = ?').run(expiresAt, ttl, requested.resource);
|
|
@@ -180,9 +177,27 @@ export function createLockCommands(deps) {
|
|
|
180
177
|
const opts = parseOpts(args, { booleans: ['all'] });
|
|
181
178
|
const db = connect(ctx);
|
|
182
179
|
const t = now();
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
180
|
+
const { locks, owners, graceUntil } = readTx(db, () => {
|
|
181
|
+
const locks = db.prepare('SELECT * FROM locks ORDER BY resource ASC').all();
|
|
182
|
+
const graceUntil = readClockGraceUntil(db);
|
|
183
|
+
const expired = opts.all || clockGraceSuppressed(t, graceUntil)
|
|
184
|
+
? [] : locks.filter((lock) => Number(lock.expires_at) <= t);
|
|
185
|
+
const owners = [...new Set(expired
|
|
186
|
+
.map((lock) => lock.owner))].map((owner) => ({
|
|
187
|
+
owner,
|
|
188
|
+
peer: db.prepare('SELECT * FROM peers WHERE id = ?').get(owner) || null,
|
|
189
|
+
binding: db.prepare('SELECT * FROM peer_bindings WHERE peer = ?').get(owner) || null
|
|
190
|
+
}));
|
|
191
|
+
return { locks, owners, graceUntil };
|
|
192
|
+
});
|
|
193
|
+
const evidenceByOwner = new Map(owners.map(({ owner, peer, binding }) => [
|
|
194
|
+
owner,
|
|
195
|
+
peer ? observePeerEvidence(ctx, peer, binding) : { state: 'unknown', reason: 'peer_missing' }
|
|
196
|
+
]));
|
|
197
|
+
const rows = opts.all ? locks : visibleLocksWithEvidence(locks, t, {
|
|
198
|
+
graceActive: clockGraceSuppressed(t, graceUntil),
|
|
199
|
+
evidenceByOwner
|
|
200
|
+
});
|
|
186
201
|
printResult(ctx, rows, (data) => table(data, [
|
|
187
202
|
{ label: 'resource', value: (r) => lockBaseResource(r) },
|
|
188
203
|
{ label: 'scope', value: (r) => lockScope(r) },
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
2
|
+
import { parseOpts, required, validateOpts, wantsHelp } from '../../cli-args.mjs';
|
|
3
|
+
import { loadScopedMcpBootstrap } from '../../mcp/scope.mjs';
|
|
4
|
+
import { createScopedMcpTools } from '../../mcp/tools.mjs';
|
|
5
|
+
import { serveMcpStdio } from '../../mcp/stdio.mjs';
|
|
6
|
+
|
|
7
|
+
export function mcpHelp() {
|
|
8
|
+
console.log(`Usage: hcc --root PROJECT --db DATABASE mcp serve --peer PEER
|
|
9
|
+
|
|
10
|
+
Serve project/peer-scoped coordination tools over MCP stdio. The executor must
|
|
11
|
+
supply a private, temporary capability; --peer alone does not grant ownership.
|
|
12
|
+
No global Codex configuration is changed. Task claiming and handoff never mark
|
|
13
|
+
tasks done. Errors from tools are returned as MCP tool results.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function createMcpCommands(dependencies) {
|
|
17
|
+
async function cmdMcp(ctx, args) {
|
|
18
|
+
if (!args[0] || wantsHelp(args)) return mcpHelp();
|
|
19
|
+
if (args[0] !== 'serve') throw new CliError('BAD_ARGS', 'Unknown MCP command');
|
|
20
|
+
const options = parseOpts(args.slice(1));
|
|
21
|
+
validateOpts('mcp serve', options, ['peer']);
|
|
22
|
+
const peer = required(options, 'peer');
|
|
23
|
+
const authority = loadScopedMcpBootstrap(ctx, peer);
|
|
24
|
+
const tools = createScopedMcpTools({ ...dependencies, ctx, authority });
|
|
25
|
+
await serveMcpStdio({ tools });
|
|
26
|
+
}
|
|
27
|
+
return { cmdMcp };
|
|
28
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
6
|
+
import { parseOpts, validateOpts, wantsHelp, intOpt } from '../../cli-args.mjs';
|
|
7
|
+
import { printResult } from '../../format.mjs';
|
|
8
|
+
import { commandPath } from '../../cli-runtime.mjs';
|
|
9
|
+
import { nativePaths, readNativePointer } from '../../runtime/native/store.mjs';
|
|
10
|
+
import { nativeRequest } from '../../runtime/native/client.mjs';
|
|
11
|
+
import { startNativeService } from '../../runtime/native/service.mjs';
|
|
12
|
+
|
|
13
|
+
export function nativeHelp() {
|
|
14
|
+
console.log(`Usage: hcc native up|status|down
|
|
15
|
+
hcc native start --peer NAME --provider codex|claude|dsh [--cwd DIR] [--model MODEL] [--binary PATH] [--resume last]
|
|
16
|
+
hcc native send --peer NAME --body TEXT [--from NAME] [--task ID]
|
|
17
|
+
hcc native events --peer NAME [--after ID]
|
|
18
|
+
hcc native deliveries [--peer NAME]
|
|
19
|
+
hcc native requests --peer NAME
|
|
20
|
+
hcc native respond --peer NAME --request ID --decision accept|decline|cancel [--response-file JSON]
|
|
21
|
+
hcc native interrupt --peer NAME [--turn ID]
|
|
22
|
+
hcc native close --peer NAME
|
|
23
|
+
|
|
24
|
+
Native workers share the project task/message/lock bus. Only HCC-owned saved
|
|
25
|
+
sessions can resume. Existing TUI/Desktop sessions keep their own transport.
|
|
26
|
+
Providers retain their permission checks; interactive requests await an explicit Web or local response.
|
|
27
|
+
Claude workers require the optional @anthropic-ai/claude-agent-sdk package.
|
|
28
|
+
dsh workers use the ACP profile, not its Web-service terminal.`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createNativeCommands(deps) {
|
|
32
|
+
async function up(ctx) {
|
|
33
|
+
if (readNativePointer(ctx)) {
|
|
34
|
+
try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 2000 }); }
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code !== 'NATIVE_RUNTIME_OFFLINE') throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const paths = nativePaths(ctx, { create: true });
|
|
40
|
+
const log = fs.openSync(paths.log, fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
41
|
+
if (!fs.fstatSync(log).isFile() || fs.fstatSync(log).nlink !== 1) {
|
|
42
|
+
fs.closeSync(log);
|
|
43
|
+
throw new CliError('NATIVE_STATE_UNSAFE', 'Native runtime log must be a private regular file');
|
|
44
|
+
}
|
|
45
|
+
const child = spawn(process.execPath, [commandPath(), '--root', fs.realpathSync(ctx.root), '--db', path.resolve(ctx.dbPath), 'native', 'serve'], {
|
|
46
|
+
cwd: ctx.root, env: process.env, detached: true, stdio: ['ignore', log, log]
|
|
47
|
+
});
|
|
48
|
+
fs.closeSync(log);
|
|
49
|
+
let failed;
|
|
50
|
+
child.on('error', (error) => { failed = error; });
|
|
51
|
+
child.unref();
|
|
52
|
+
const deadline = Date.now() + 10000;
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
if (failed) throw new CliError('NATIVE_RUNTIME_START_FAILED', failed.message);
|
|
55
|
+
if (readNativePointer(ctx)) {
|
|
56
|
+
try { return await nativeRequest(ctx, 'GET', '/status', null, { timeoutMs: 1000 }); } catch {}
|
|
57
|
+
}
|
|
58
|
+
await delay(100);
|
|
59
|
+
}
|
|
60
|
+
throw new CliError('NATIVE_RUNTIME_START_FAILED', `Native runtime did not become ready. Inspect ${paths.log}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function cmdNative(ctx, args) {
|
|
64
|
+
const command = args[0];
|
|
65
|
+
if (!command || wantsHelp(args)) return nativeHelp();
|
|
66
|
+
const opts = parseOpts(args.slice(1));
|
|
67
|
+
const allowed = {
|
|
68
|
+
up: [], serve: [], down: [], status: [],
|
|
69
|
+
start: ['peer', 'provider', 'cwd', 'model', 'binary', 'resume'],
|
|
70
|
+
send: ['peer', 'body', 'from', 'task'],
|
|
71
|
+
events: ['peer', 'after'], deliveries: ['peer'], requests: ['peer'],
|
|
72
|
+
respond: ['peer', 'request', 'decision', 'response-file'],
|
|
73
|
+
interrupt: ['peer', 'turn'], close: ['peer']
|
|
74
|
+
};
|
|
75
|
+
if (!Object.hasOwn(allowed, command)) throw new CliError('BAD_ARGS', `Unknown native command: ${command}`);
|
|
76
|
+
validateOpts(`native ${command}`, opts, allowed[command]);
|
|
77
|
+
if (['start', 'send', 'events', 'interrupt', 'close', 'requests', 'respond'].includes(command) && !opts.peer) throw new CliError('BAD_ARGS', 'Missing --peer');
|
|
78
|
+
if (command === 'start' && !opts.provider) throw new CliError('BAD_ARGS', 'Missing --provider');
|
|
79
|
+
if (command === 'start' && opts.provider === 'claude' && opts.binary !== undefined) throw new CliError('BAD_ARGS', 'Claude workers use the Agent SDK; --binary is available for Codex and dsh');
|
|
80
|
+
if (command === 'send' && !opts.body?.trim()) throw new CliError('BAD_ARGS', 'Missing --body');
|
|
81
|
+
let result;
|
|
82
|
+
if (command === 'up') result = await up(ctx);
|
|
83
|
+
else if (command === 'serve') {
|
|
84
|
+
const service = await startNativeService(ctx, deps);
|
|
85
|
+
const shutdown = () => { void service.shutdown().catch((error) => console.error(JSON.stringify({ code: error.code, message: error.message }))); };
|
|
86
|
+
process.once('SIGTERM', shutdown);
|
|
87
|
+
process.once('SIGINT', shutdown);
|
|
88
|
+
return;
|
|
89
|
+
} else if (command === 'start') {
|
|
90
|
+
await up(ctx);
|
|
91
|
+
result = await nativeRequest(ctx, 'POST', '/workers', { peer: opts.peer, provider: opts.provider,
|
|
92
|
+
cwd: opts.cwd ? path.resolve(ctx.cwd, opts.cwd) : ctx.root, model: opts.model, binary: opts.binary, resume: opts.resume });
|
|
93
|
+
} else if (command === 'send') {
|
|
94
|
+
const taskId = intOpt(opts, 'task', null);
|
|
95
|
+
if (taskId !== null && taskId < 1) throw new CliError('BAD_ARGS', 'Task ID must be positive');
|
|
96
|
+
const sender = deps.resolveCurrentPeer(ctx, opts, 'from', 'shell').id;
|
|
97
|
+
result = await nativeRequest(ctx, 'POST', '/send', { peer: opts.peer, from: sender, body: opts.body, taskId });
|
|
98
|
+
} else if (command === 'events') {
|
|
99
|
+
const after = intOpt(opts, 'after', 0);
|
|
100
|
+
if (after < 0) throw new CliError('BAD_ARGS', 'Event cursor must be nonnegative');
|
|
101
|
+
result = await nativeRequest(ctx, 'GET', `/events?peer=${encodeURIComponent(opts.peer)}&after=${after}`);
|
|
102
|
+
} else if (command === 'deliveries') {
|
|
103
|
+
result = await nativeRequest(ctx, 'GET', `/deliveries${opts.peer ? `?peer=${encodeURIComponent(opts.peer)}` : ''}`);
|
|
104
|
+
} else if (command === 'requests' || command === 'respond') {
|
|
105
|
+
const state = await nativeRequest(ctx, 'GET', '/workers/' + encodeURIComponent(opts.peer) + '/state');
|
|
106
|
+
if (command === 'requests') result = state.snapshot.pendingApprovals || [];
|
|
107
|
+
else {
|
|
108
|
+
if (!opts.request || !['accept', 'decline', 'cancel'].includes(opts.decision)) throw new CliError('BAD_ARGS', 'Respond requires --request ID and --decision accept|decline|cancel');
|
|
109
|
+
const pending = (state.snapshot.pendingApprovals || []).filter(request => String(request.requestId) === opts.request);
|
|
110
|
+
if (pending.length !== 1) throw new CliError('NATIVE_APPROVAL_MISMATCH', 'Select an exact pending request from hcc native requests');
|
|
111
|
+
let response = {};
|
|
112
|
+
if (opts['response-file']) {
|
|
113
|
+
const data = fs.readFileSync(path.resolve(ctx.cwd, opts['response-file']), 'utf8');
|
|
114
|
+
if (data.length > 100000) throw new CliError('BAD_ARGS', 'Response file exceeds the limit');
|
|
115
|
+
try { response = JSON.parse(data); } catch { throw new CliError('BAD_ARGS', 'Response file must contain JSON'); }
|
|
116
|
+
if (!response || typeof response !== 'object' || Array.isArray(response) || Object.keys(response).some(key => !['permissions','scope','answers'].includes(key))) throw new CliError('BAD_ARGS', 'Response file accepts permissions, scope and answers only');
|
|
117
|
+
}
|
|
118
|
+
const request = pending[0];
|
|
119
|
+
result = await nativeRequest(ctx, 'POST', '/respond', { peer: opts.peer, generation: state.generation,
|
|
120
|
+
owner: state.owner, provider: state.provider, executorId: state.owner, sessionId: request.sessionId,
|
|
121
|
+
turnId: request.turnId, requestId: request.requestId, decision: opts.decision, ...response });
|
|
122
|
+
}
|
|
123
|
+
} else if (command === 'interrupt') result = await nativeRequest(ctx, 'POST', '/interrupt', { peer: opts.peer, turnId: opts.turn });
|
|
124
|
+
else if (command === 'close') result = await nativeRequest(ctx, 'POST', '/close', { peer: opts.peer });
|
|
125
|
+
else if (command === 'down') result = await nativeRequest(ctx, 'POST', '/down', {});
|
|
126
|
+
else result = await nativeRequest(ctx, 'GET', '/status');
|
|
127
|
+
printResult(ctx, result, (data) => JSON.stringify(data, null, 2));
|
|
128
|
+
}
|
|
129
|
+
return { cmdNative };
|
|
130
|
+
}
|
package/lib/cli/commands/run.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
readExternalBufferMetadata
|
|
19
19
|
} from '../../runtime/buffer-evidence.mjs';
|
|
20
20
|
import { inspectProcessIdentity } from '../../process/identity.mjs';
|
|
21
|
+
import { resolvePeerEvidence } from '../../core/peers/evidence.mjs';
|
|
21
22
|
import {
|
|
22
23
|
capturePtyStartupEvidence,
|
|
23
24
|
installPtyTerminationHandlers,
|
|
@@ -73,22 +74,58 @@ async function cmdRun(ctx, args) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
const db = connect(ctx);
|
|
77
|
+
const wrapperIdentity = runtimeProcessIdentity();
|
|
76
78
|
try {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
79
|
+
let registered = false;
|
|
80
|
+
for (let attempt = 0; attempt < 2 && !registered; attempt += 1) {
|
|
81
|
+
const previous = db.prepare('SELECT * FROM peers WHERE id = ?').get(id) || null;
|
|
82
|
+
const evidence = previous ? resolvePeerEvidence({
|
|
83
|
+
peer: previous,
|
|
84
|
+
processes: [{
|
|
85
|
+
storedIdentity: {
|
|
86
|
+
pid: Number(previous.pid),
|
|
87
|
+
startToken: previous.pid_start_token,
|
|
88
|
+
commandHash: previous.pid_command_hash
|
|
89
|
+
},
|
|
90
|
+
current: inspectProcessIdentity(previous.pid)
|
|
91
|
+
}]
|
|
92
|
+
}) : null;
|
|
93
|
+
registered = tx(db, () => {
|
|
94
|
+
const current = db.prepare('SELECT * FROM peers WHERE id = ?').get(id) || null;
|
|
95
|
+
if (JSON.stringify(current) !== JSON.stringify(previous)) return false;
|
|
96
|
+
if (previous && evidence.state !== 'dead' && (
|
|
97
|
+
previous.pid || ['running', 'working', 'busy', 'starting', 'blocked', 'detached'].includes(previous.status)
|
|
98
|
+
)) {
|
|
99
|
+
throw new CliError('PEER_SESSION_EXISTS', `Peer ${id} already has a live or unresolved owner`, {
|
|
100
|
+
peer: id,
|
|
101
|
+
evidence_state: evidence.state
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
upsertPeer(db, {
|
|
105
|
+
id, kind, role,
|
|
106
|
+
worktree: cwd,
|
|
107
|
+
branch: detectBranch(cwd),
|
|
108
|
+
pid: process.pid,
|
|
109
|
+
processIdentity: wrapperIdentity,
|
|
110
|
+
status: 'running',
|
|
111
|
+
capabilities: 'run-wrapper'
|
|
112
|
+
});
|
|
113
|
+
upsertCanonicalPeerBinding(db, binding, Boolean(opts.force));
|
|
114
|
+
addEvent(db, 'run.session.started', id, null, auditPayload({
|
|
115
|
+
actor: id,
|
|
116
|
+
target: id,
|
|
117
|
+
command: [command, ...commandArgs].join(' '),
|
|
118
|
+
cwd
|
|
119
|
+
}));
|
|
120
|
+
return true;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
if (!registered) {
|
|
124
|
+
throw new CliError('SUBJECT_CHANGED', `Peer ${id} changed while its owner was being observed; retry`, {
|
|
125
|
+
peer: id,
|
|
126
|
+
retryable: true
|
|
127
|
+
});
|
|
128
|
+
}
|
|
92
129
|
} finally {
|
|
93
130
|
db.close();
|
|
94
131
|
}
|
|
@@ -107,12 +144,20 @@ async function cmdRun(ctx, args) {
|
|
|
107
144
|
});
|
|
108
145
|
const db2 = connect(ctx);
|
|
109
146
|
try {
|
|
110
|
-
db2
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
147
|
+
tx(db2, () => {
|
|
148
|
+
const mutation = db2.prepare(`
|
|
149
|
+
UPDATE peers SET status = ?, last_seen_at = ?
|
|
150
|
+
WHERE id = ? AND pid IS ? AND pid_start_token IS ? AND pid_command_hash IS ?
|
|
151
|
+
`).run('exited', now(), id, process.pid, wrapperIdentity?.startToken || null,
|
|
152
|
+
wrapperIdentity?.commandHash || null);
|
|
153
|
+
if (Number(mutation.changes) > 0) {
|
|
154
|
+
addEvent(db2, 'run.session.exited', id, null, auditPayload({
|
|
155
|
+
actor: id,
|
|
156
|
+
target: id,
|
|
157
|
+
...exitCode
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
116
161
|
} finally {
|
|
117
162
|
db2.close();
|
|
118
163
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// Task CLI commands extracted from bin/hcc.mjs.
|
|
2
2
|
|
|
3
|
+
import { visibleLocksWithEvidence } from '../../core/coordination/locks.mjs';
|
|
4
|
+
|
|
3
5
|
const TASK_STATUS_SHORTCUTS = new Set(['running', 'review', 'blocked', 'abandoned']);
|
|
4
6
|
|
|
5
7
|
export function createTaskCommands(deps) {
|
|
@@ -270,9 +272,10 @@ export function createTaskCommands(deps) {
|
|
|
270
272
|
peerRow.evidence_reason = evidence.reason;
|
|
271
273
|
}
|
|
272
274
|
const graceUntil = readClockGraceUntil(db);
|
|
273
|
-
const locks =
|
|
274
|
-
|
|
275
|
-
:
|
|
275
|
+
const locks = visibleLocksWithEvidence(db.prepare('SELECT * FROM locks').all(), t, {
|
|
276
|
+
graceActive: clockGraceSuppressed(t, graceUntil),
|
|
277
|
+
evidenceByOwner: new Map(peers.map((peer) => [peer.id, { state: peer.evidence_state }]))
|
|
278
|
+
});
|
|
276
279
|
rows = annotateTasksWithLiveness(rows, peers, locks, t, ACTIVE_PEER_TTL, graceUntil);
|
|
277
280
|
printResult(ctx, rows, (data) => table(data, [
|
|
278
281
|
{ label: 'id', value: (r) => `#${r.id}` },
|
package/lib/cli-args.mjs
CHANGED
|
@@ -6,6 +6,10 @@ export function splitGlobalArgs(argv) {
|
|
|
6
6
|
const rest = [];
|
|
7
7
|
for (let i = 0; i < argv.length; i += 1) {
|
|
8
8
|
const arg = argv[i];
|
|
9
|
+
if (arg === '--') {
|
|
10
|
+
rest.push(...argv.slice(i));
|
|
11
|
+
break;
|
|
12
|
+
}
|
|
9
13
|
if (arg === '--json') {
|
|
10
14
|
global.json = true;
|
|
11
15
|
} else if (arg === '--root') {
|
|
@@ -11,6 +11,9 @@ import { timelineFromRows } from './core/coordination/timeline.mjs';
|
|
|
11
11
|
import { readTx } from './db/schema.mjs';
|
|
12
12
|
import { clockGraceSuppressed, readClockGraceUntil } from './shared/clock-grace.mjs';
|
|
13
13
|
import { classifyPeerActivity } from './core/peers/evidence.mjs';
|
|
14
|
+
import { shellQuoteArg } from './format.mjs';
|
|
15
|
+
import process from 'node:process';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
14
17
|
|
|
15
18
|
const DEFAULT_ACTIVE_PEER_TTL = 600;
|
|
16
19
|
const DEFAULT_LOCK_TTL = 900;
|
|
@@ -147,13 +150,24 @@ export function createCoordinationState(deps = {}) {
|
|
|
147
150
|
'[hello-cc coordination]',
|
|
148
151
|
`peer: ${peerId}`,
|
|
149
152
|
'This is live project coordination context injected by hello-cc.',
|
|
150
|
-
'You are not isolated for project coordination: hcc is the source of truth for other Claude/Codex/shell sessions in this project.',
|
|
153
|
+
'You are not isolated for project coordination: hcc is the source of truth for other Claude/Codex/dsh/shell sessions in this project.',
|
|
151
154
|
'If the user asks what other sessions are doing, what tasks exist, or whether you can see other sessions, do not answer from generic model knowledge and do not say sessions are isolated. Run hcc status, hcc state, hcc peers, hcc task list, hcc msg inbox, and hcc lock list, then answer from those results.',
|
|
152
155
|
'Tasks are project work facts, not read/unread items. Open tasks stay relevant to every session until they are marked done or abandoned. Messages are the unread/ack notification channel.',
|
|
153
156
|
'If you already own a current task, continue that task until handoff/done/blocked; do not claim a different task just because a new prompt arrived.',
|
|
154
157
|
'When a hello-cc message asks for a response, reply with hcc msg reply --id <message-id> --body "<answer>" so the answer stays in the same thread.'
|
|
155
158
|
];
|
|
156
159
|
|
|
160
|
+
if (snapshot.peers.some((peer) => peer.id === peerId && peer.kind === 'dsh')) {
|
|
161
|
+
const hccBin = fileURLToPath(new URL('../bin/hcc.mjs', import.meta.url));
|
|
162
|
+
const command = ['env', 'HCC_PEER=' + peerId, 'HCC_ROOT=' + ctx.root,
|
|
163
|
+
'HCC_DB=' + ctx.dbPath, process.execPath, hccBin].map(shellQuoteArg).join(' ');
|
|
164
|
+
parts.push('[hello-cc dsh session commands]');
|
|
165
|
+
parts.push('Every dsh agent has its own peer identity. Use this exact command prefix for ALL hcc commands, including the commands shown above and below:');
|
|
166
|
+
parts.push(command);
|
|
167
|
+
parts.push(`For example: ${command} status; ${command} msg inbox; ${command} msg reply --id <message-id> --body "<answer>".`);
|
|
168
|
+
parts.push('The prefix selects this session and project even when other session variables are inherited. Do not use a bare hcc command or borrow another peer identity.');
|
|
169
|
+
}
|
|
170
|
+
|
|
157
171
|
if (peers.length > 0) {
|
|
158
172
|
parts.push('[hello-cc known peers]');
|
|
159
173
|
parts.push(...peers.map((peer) => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const
|
|
1
|
+
export const DURABLE_RUNTIME_EVENTS = `
|
|
2
2
|
SELECT MAX(e.id)
|
|
3
3
|
FROM events e
|
|
4
4
|
JOIN peer_bindings b
|
|
@@ -7,10 +7,22 @@ const CURRENT_TMUX_AUTHORITIES = `
|
|
|
7
7
|
AND b.transport = 'tmux'
|
|
8
8
|
AND (b.runtime_target IS NULL OR b.runtime_target = json_extract(e.payload, '$.pane'))
|
|
9
9
|
GROUP BY b.peer, b.runtime_target
|
|
10
|
+
UNION
|
|
11
|
+
SELECT MAX(e.id) FROM events e
|
|
12
|
+
WHERE e.type IN ('web.adoption.paused','web.adoption.resumed') AND json_valid(e.payload)
|
|
13
|
+
GROUP BY json_extract(e.payload, '$.peer'), json_extract(e.payload, '$.pane')
|
|
14
|
+
UNION
|
|
15
|
+
SELECT e.id FROM events e WHERE e.type = 'codex.submission.pending'
|
|
16
|
+
UNION
|
|
17
|
+
SELECT e.id FROM events e WHERE e.type = 'task.result.recorded'
|
|
18
|
+
UNION
|
|
19
|
+
SELECT e.id FROM events e WHERE e.type = 'native.web.submission.pending'
|
|
20
|
+
UNION
|
|
21
|
+
SELECT e.id FROM events e WHERE e.type = 'codex.executor.started'
|
|
10
22
|
`;
|
|
11
23
|
|
|
12
24
|
export function pruneOldEventsPreservingTmuxAuthority(db, cutoff, options = {}) {
|
|
13
|
-
const predicate = `created_at < ? AND id NOT IN (${
|
|
25
|
+
const predicate = `created_at < ? AND id NOT IN (${DURABLE_RUNTIME_EVENTS})`;
|
|
14
26
|
const count = db.prepare(`SELECT COUNT(*) AS n FROM events WHERE ${predicate}`).get(cutoff).n;
|
|
15
27
|
if (!options.dryRun) db.prepare(`DELETE FROM events WHERE ${predicate}`).run(cutoff);
|
|
16
28
|
return Number(count || 0);
|
|
@@ -2,23 +2,13 @@ import { randomBytes } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
import { readTx, tx } from '../../db/schema.mjs';
|
|
4
4
|
import { aggregateCleanupFailure } from '../../shared/cleanup-error.mjs';
|
|
5
|
+
import { DURABLE_RUNTIME_EVENTS } from './event-retention.mjs';
|
|
5
6
|
|
|
6
7
|
export const HISTORY_GC_BATCH_SIZE = 256;
|
|
7
8
|
|
|
8
9
|
const HISTORY_CATEGORIES = Object.freeze(['events', 'tasks', 'messages', 'handoffs']);
|
|
9
10
|
const PLAN_CHANGED = Symbol('gc-plan-changed');
|
|
10
11
|
|
|
11
|
-
const CURRENT_TMUX_AUTHORITIES = `
|
|
12
|
-
SELECT MAX(e.id)
|
|
13
|
-
FROM events e
|
|
14
|
-
JOIN peer_bindings b
|
|
15
|
-
ON b.peer = json_extract(e.payload, '$.target_peer')
|
|
16
|
-
WHERE e.type = 'tmux.session.attached'
|
|
17
|
-
AND b.transport = 'tmux'
|
|
18
|
-
AND (b.runtime_target IS NULL OR b.runtime_target = json_extract(e.payload, '$.pane'))
|
|
19
|
-
GROUP BY b.peer, b.runtime_target
|
|
20
|
-
`;
|
|
21
|
-
|
|
22
12
|
function rowById(db, table, column, value) {
|
|
23
13
|
return db.prepare(`SELECT * FROM ${table} WHERE ${column} = ?`).get(value) || null;
|
|
24
14
|
}
|
|
@@ -108,7 +98,7 @@ function insertSnapshotCategory(db, snapshot, category) {
|
|
|
108
98
|
db.prepare(`
|
|
109
99
|
INSERT INTO ${table}(category, id)
|
|
110
100
|
SELECT 'events', id FROM events
|
|
111
|
-
WHERE created_at < ? AND id NOT IN (${
|
|
101
|
+
WHERE created_at < ? AND id NOT IN (${DURABLE_RUNTIME_EVENTS})
|
|
112
102
|
`).run(snapshot.cutoff);
|
|
113
103
|
} else if (category === 'tasks') {
|
|
114
104
|
db.prepare(`
|
|
@@ -416,7 +406,7 @@ function captureHistoryGcPlanFromSnapshot(db, cutoff, options, snapshot) {
|
|
|
416
406
|
FROM ${table} s
|
|
417
407
|
JOIN events e ON e.id = s.id
|
|
418
408
|
WHERE s.category = 'events' AND s.id > ? AND e.created_at < ?
|
|
419
|
-
AND e.id NOT IN (${
|
|
409
|
+
AND e.id NOT IN (${DURABLE_RUNTIME_EVENTS})
|
|
420
410
|
ORDER BY s.id ASC
|
|
421
411
|
LIMIT ?
|
|
422
412
|
`).all(after.events, cutoff, allocation);
|
|
@@ -44,3 +44,48 @@ export function locksConflict(a, b) {
|
|
|
44
44
|
return lockBaseResource(a) === lockBaseResource(b) &&
|
|
45
45
|
(lockScope(a) === WHOLE_LOCK_SCOPE || lockScope(b) === WHOLE_LOCK_SCOPE || lockScope(a) === lockScope(b));
|
|
46
46
|
}
|
|
47
|
+
|
|
48
|
+
export function findRequestedLock(db, requested) {
|
|
49
|
+
const row = db.prepare('SELECT * FROM locks WHERE resource = ?').get(requested.resource) || null;
|
|
50
|
+
return row && lockBaseResource(row) === requested.base_resource && lockScope(row) === requested.scope
|
|
51
|
+
? row
|
|
52
|
+
: null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Keep the persisted key format compatible with existing locks. A whole-resource
|
|
56
|
+
// name can equal a scoped key, but that physical collision is never authority to
|
|
57
|
+
// update or remove the other logical resource.
|
|
58
|
+
export function upsertRequestedLock(db, requested, lease) {
|
|
59
|
+
const result = db.prepare(`
|
|
60
|
+
INSERT INTO locks(resource, base_resource, scope, owner, task_id, reason, expires_at, created_at, ttl_sec)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
62
|
+
ON CONFLICT(resource) DO UPDATE SET
|
|
63
|
+
base_resource = excluded.base_resource,
|
|
64
|
+
scope = excluded.scope,
|
|
65
|
+
owner = excluded.owner,
|
|
66
|
+
task_id = excluded.task_id,
|
|
67
|
+
reason = excluded.reason,
|
|
68
|
+
expires_at = excluded.expires_at,
|
|
69
|
+
created_at = excluded.created_at,
|
|
70
|
+
ttl_sec = excluded.ttl_sec
|
|
71
|
+
WHERE COALESCE(NULLIF(locks.base_resource, ''), locks.resource) = excluded.base_resource
|
|
72
|
+
AND COALESCE(NULLIF(locks.scope, ''), '*') = excluded.scope
|
|
73
|
+
`).run(requested.resource, requested.base_resource, requested.scope, lease.owner,
|
|
74
|
+
lease.task_id ?? null, lease.reason || '', lease.expires_at, lease.created_at, lease.ttl_sec);
|
|
75
|
+
if (Number(result.changes) !== 1) {
|
|
76
|
+
throw new CliError('LOCK_RESOURCE_COLLISION', `Resource ${lockLabel(requested)} collides with an existing lock key`, {
|
|
77
|
+
resource: requested.base_resource,
|
|
78
|
+
scope: requested.scope,
|
|
79
|
+
lock_resource: requested.resource
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return findRequestedLock(db, requested);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function visibleLocksWithEvidence(locks, nowSec, {
|
|
86
|
+
graceActive = false,
|
|
87
|
+
evidenceByOwner = new Map()
|
|
88
|
+
} = {}) {
|
|
89
|
+
return (locks || []).filter((lock) => graceActive || Number(lock.expires_at) > nowSec ||
|
|
90
|
+
evidenceByOwner.get(lock.owner)?.state === 'live');
|
|
91
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { tx } from '../../db/schema.mjs';
|
|
3
|
+
import { CliError } from '../../shared/errors.mjs';
|
|
4
|
+
|
|
5
|
+
export const RESULT_EVENT = 'task.result.recorded';
|
|
6
|
+
const STAGES = new Set(['local', 'publication', 'business']);
|
|
7
|
+
const STATUSES = new Set(['passed', 'failed', 'pending']);
|
|
8
|
+
const KINDS = new Set(['verification', 'command', 'diff']);
|
|
9
|
+
|
|
10
|
+
function text(value, field, max, required = false) {
|
|
11
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
12
|
+
throw new CliError('BAD_REQUEST', `${field} must be text`);
|
|
13
|
+
}
|
|
14
|
+
const result = (value || '').trim();
|
|
15
|
+
if ((required && !result) || result.length > max) throw new CliError('BAD_REQUEST', `${field} must contain ${required ? '1' : '0'} to ${max} characters`);
|
|
16
|
+
return result;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Evidence records do not mutate task status. The owner is checked in the same
|
|
20
|
+
// transaction as the insert, including after a concurrent task takeover.
|
|
21
|
+
export function recordTaskResult(db, input, { now = () => Math.floor(Date.now() / 1000), addEvent } = {}) {
|
|
22
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new CliError('BAD_REQUEST', 'A result object is required');
|
|
23
|
+
const peer = text(input.peer, 'peer', 200, true);
|
|
24
|
+
const taskId = input.taskId;
|
|
25
|
+
if (!Number.isSafeInteger(taskId) || taskId < 1) throw new CliError('BAD_REQUEST', 'taskId must be a positive integer');
|
|
26
|
+
const stage = input.stage || 'local', status = input.status || 'pending';
|
|
27
|
+
const kind = input.kind || 'verification', source = input.source || 'user';
|
|
28
|
+
if (!STAGES.has(stage) || !STATUSES.has(status) || !KINDS.has(kind) || !['user', 'executor'].includes(source)) {
|
|
29
|
+
throw new CliError('BAD_REQUEST', 'Unsupported result stage, status, kind or source');
|
|
30
|
+
}
|
|
31
|
+
if (source === 'executor' && stage !== 'local') throw new CliError('BAD_REQUEST', 'Executor records can only report local evidence');
|
|
32
|
+
const evidence = input.evidence || [];
|
|
33
|
+
if (!Array.isArray(evidence) || evidence.length > 20) throw new CliError('BAD_REQUEST', 'evidence must contain up to 20 text references');
|
|
34
|
+
const references = evidence.map((entry) => text(entry, 'evidence', 2000, true));
|
|
35
|
+
if (source === 'user' && status === 'passed' && !references.length) {
|
|
36
|
+
throw new CliError('BAD_REQUEST', 'A passed verification needs an evidence reference');
|
|
37
|
+
}
|
|
38
|
+
const record = {
|
|
39
|
+
result_id: randomUUID(), result_key: text(input.resultKey, 'resultKey', 700) || randomUUID(),
|
|
40
|
+
peer, task_id: taskId, stage, status, kind, source,
|
|
41
|
+
title: text(input.title, 'title', 300, true), details: text(input.details, 'details', 64000), evidence: references,
|
|
42
|
+
command: text(input.command, 'command', 64000), diff: text(input.diff, 'diff', 64000),
|
|
43
|
+
executor_id: text(input.executorId, 'executorId', 512) || null,
|
|
44
|
+
thread_id: text(input.threadId, 'threadId', 512) || null,
|
|
45
|
+
turn_id: text(input.turnId, 'turnId', 512) || null,
|
|
46
|
+
item_id: text(input.itemId, 'itemId', 512) || null,
|
|
47
|
+
exit_code: Number.isSafeInteger(input.exitCode) ? input.exitCode : null,
|
|
48
|
+
created_at: now()
|
|
49
|
+
};
|
|
50
|
+
return tx(db, () => {
|
|
51
|
+
const task = db.prepare('SELECT id, owner, title, status FROM tasks WHERE id = ?').get(taskId);
|
|
52
|
+
if (!task) throw new CliError('NOT_FOUND', 'Task not found');
|
|
53
|
+
if (task.owner !== peer) throw new CliError('TASK_OWNER_MISMATCH', 'This task belongs to a different peer');
|
|
54
|
+
const previous = db.prepare(`SELECT id, payload, created_at FROM events WHERE type = ?
|
|
55
|
+
AND task_id = ? AND actor = ? AND json_valid(payload) AND json_extract(payload, '$.result_key') = ?
|
|
56
|
+
ORDER BY id DESC LIMIT 1`).get(RESULT_EVENT, taskId, peer, record.result_key);
|
|
57
|
+
if (previous) return { ...JSON.parse(previous.payload), id: previous.id, created_at: previous.created_at };
|
|
58
|
+
record.task_title = task.title;
|
|
59
|
+
record.task_status = task.status;
|
|
60
|
+
if (addEvent) addEvent(db, RESULT_EVENT, peer, taskId, record);
|
|
61
|
+
else db.prepare('INSERT INTO events(type,actor,task_id,payload,created_at) VALUES(?,?,?,?,?)')
|
|
62
|
+
.run(RESULT_EVENT, peer, taskId, JSON.stringify(record), record.created_at);
|
|
63
|
+
return { ...record, id: Number(db.prepare('SELECT last_insert_rowid() AS id').get().id) };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function listTaskResults(db, { peer, taskId = null, limit = 100 } = {}) {
|
|
68
|
+
if (!peer || !Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new CliError('BAD_REQUEST', 'peer and a limit from 1 to 200 are required');
|
|
69
|
+
if (taskId !== null && (!Number.isSafeInteger(taskId) || taskId < 1)) throw new CliError('BAD_REQUEST', 'taskId must be a positive integer');
|
|
70
|
+
return db.prepare(`SELECT id,payload,created_at FROM events
|
|
71
|
+
WHERE type = ? AND actor = ? AND (? IS NULL OR task_id = ?) AND json_valid(payload)
|
|
72
|
+
ORDER BY id DESC LIMIT ?`).all(RESULT_EVENT, peer, taskId, taskId, limit)
|
|
73
|
+
.map((row) => ({ ...JSON.parse(row.payload), id: row.id, created_at: row.created_at }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function summarizeTaskResults(results) {
|
|
77
|
+
return Object.fromEntries([...STAGES].map((stage) => [stage,
|
|
78
|
+
results.find((record) => record.stage === stage && record.kind === 'verification') || null]));
|
|
79
|
+
}
|