@worca/app 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +403 -0
- package/agents/clarify.meta.json +19 -0
- package/agents/decomposer.meta.json +21 -0
- package/agents/implementer.meta.json +20 -0
- package/agents/manualTestsChecklist.meta.json +18 -0
- package/agents/manualWebUiTesting.meta.json +18 -0
- package/agents/planReviewer.meta.json +19 -0
- package/agents/planner.meta.json +20 -0
- package/agents/refiner.meta.json +19 -0
- package/agents/reviewer.meta.json +19 -0
- package/agents/worca-cc-clarify.md +67 -0
- package/agents/worca-cc-code-reviewer.md +66 -0
- package/agents/worca-cc-decomposer.md +84 -0
- package/agents/worca-cc-implementer.md +69 -0
- package/agents/worca-cc-manual-tests-checklist.md +63 -0
- package/agents/worca-cc-manual-web-ui-testing.md +64 -0
- package/agents/worca-cc-plan-refiner.md +69 -0
- package/agents/worca-cc-plan-reviewer.md +70 -0
- package/agents/worca-cc-planner.md +70 -0
- package/agents/worca-cc-workspace-reviewer.md +56 -0
- package/agents/worca-cc-workspace-scanner.md +55 -0
- package/agents/workspaceReviewer.meta.json +20 -0
- package/agents/workspaceScanner.meta.json +18 -0
- package/package.json +61 -0
- package/scripts/install.mjs +209 -0
- package/skills/worca/SKILL.md +66 -0
- package/src/cli/worca-cc.mjs +1520 -0
- package/src/core/agent-gen.mjs +206 -0
- package/src/core/agent-registry.mjs +417 -0
- package/src/core/agent-store.mjs +143 -0
- package/src/core/artifacts.mjs +2019 -0
- package/src/core/channels.mjs +302 -0
- package/src/core/chat/allowlist.mjs +27 -0
- package/src/core/chat/channel-host.mjs +562 -0
- package/src/core/chat/channel-protocol.mjs +117 -0
- package/src/core/chat/channel-worker-child.mjs +211 -0
- package/src/core/chat/chat-context.mjs +66 -0
- package/src/core/chat/command-router.mjs +343 -0
- package/src/core/chat/notifier.mjs +120 -0
- package/src/core/chat/parser.mjs +30 -0
- package/src/core/chat/rate-limiter.mjs +133 -0
- package/src/core/chat/redact.mjs +27 -0
- package/src/core/chat/renderers.mjs +136 -0
- package/src/core/claude-runner.mjs +1356 -0
- package/src/core/config.mjs +882 -0
- package/src/core/cost-budget.mjs +103 -0
- package/src/core/db.mjs +864 -0
- package/src/core/fanout.mjs +48 -0
- package/src/core/folder-dialog.mjs +138 -0
- package/src/core/fs-browse.mjs +49 -0
- package/src/core/git-info.mjs +200 -0
- package/src/core/guardrail-store.mjs +204 -0
- package/src/core/guardrails.mjs +302 -0
- package/src/core/marketplaces.mjs +267 -0
- package/src/core/migrate-fs-to-db.mjs +612 -0
- package/src/core/model-env.mjs +74 -0
- package/src/core/orchestrator.mjs +4279 -0
- package/src/core/overview-agent.mjs +124 -0
- package/src/core/phases.mjs +1279 -0
- package/src/core/pipeline-delete.mjs +428 -0
- package/src/core/plugin-api.mjs +13 -0
- package/src/core/plugin-config.mjs +100 -0
- package/src/core/plugin-inventory.mjs +50 -0
- package/src/core/plugin-manifest.mjs +447 -0
- package/src/core/plugin-models.mjs +130 -0
- package/src/core/plugin-repo.mjs +303 -0
- package/src/core/plugin-shim-child.mjs +76 -0
- package/src/core/plugin-shim.mjs +197 -0
- package/src/core/plugin-store.mjs +485 -0
- package/src/core/plugin-workflows.mjs +179 -0
- package/src/core/plugins-lock.mjs +49 -0
- package/src/core/preflight-node.mjs +122 -0
- package/src/core/preflight.mjs +341 -0
- package/src/core/projects.mjs +157 -0
- package/src/core/protocol.mjs +257 -0
- package/src/core/recoverable-error.mjs +51 -0
- package/src/core/results.mjs +188 -0
- package/src/core/run-context.mjs +1375 -0
- package/src/core/run-log.mjs +64 -0
- package/src/core/run-manifest.mjs +317 -0
- package/src/core/runners.mjs +167 -0
- package/src/core/settings.mjs +682 -0
- package/src/core/skills.mjs +210 -0
- package/src/core/sources.mjs +232 -0
- package/src/core/stats.mjs +182 -0
- package/src/core/store.mjs +67 -0
- package/src/core/title.mjs +64 -0
- package/src/core/workflow-validator.mjs +185 -0
- package/src/core/workflows.mjs +568 -0
- package/src/core/workspace-scan.mjs +420 -0
- package/src/core/workspaces.mjs +353 -0
- package/src/core/worktree.mjs +708 -0
- package/src/feature.mjs +9 -0
- package/ui/public/app.js +10647 -0
- package/ui/public/assets/worca-favicon.png +0 -0
- package/ui/public/assets/worca-logo.png +0 -0
- package/ui/public/chat-settings-view.mjs +89 -0
- package/ui/public/composer-core.mjs +211 -0
- package/ui/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-400-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-500-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-600-normal.woff2 +0 -0
- package/ui/public/fonts/poppins-latin-700-normal.woff2 +0 -0
- package/ui/public/guardrails-view.mjs +244 -0
- package/ui/public/index.html +1145 -0
- package/ui/public/log-filter.mjs +81 -0
- package/ui/public/log-line.mjs +86 -0
- package/ui/public/models-view.mjs +433 -0
- package/ui/public/plugins-view.mjs +430 -0
- package/ui/public/results-view.mjs +121 -0
- package/ui/public/source-pane.mjs +156 -0
- package/ui/public/stats-view.mjs +523 -0
- package/ui/public/style.css +1557 -0
- package/ui/server.mjs +3573 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// src/core/chat/channel-worker-child.mjs
|
|
2
|
+
// Persistent channel-worker bootstrap (chat-connectivity-design.md §4.4) — the
|
|
3
|
+
// long-lived analog of plugin-shim-child.mjs. Runs with a scrubbed env
|
|
4
|
+
// ({PATH, HOME}), so it imports NOTHING from the worca core graph except the
|
|
5
|
+
// dependency-free channel-protocol module. Protocol: JSON-lines both ways;
|
|
6
|
+
// first host frame is `hello` (config+secrets+state via stdin ONLY, never
|
|
7
|
+
// argv/env); the process stays up until `shutdown`/stdin-EOF or a crash
|
|
8
|
+
// (nonzero exit -> the supervisor restarts with backoff).
|
|
9
|
+
//
|
|
10
|
+
// The plugin module must export:
|
|
11
|
+
// createChannelWorker(ctx) -> { start(), stop(), send(chatId, msg),
|
|
12
|
+
// handleWebhook?(req), onConfig?(config) }
|
|
13
|
+
// validateConfig?(config) -> {ok:true, identity?} | {ok:false, errors:[...]}
|
|
14
|
+
// ctx = { apiVersion, platform, mock, config, state:{get,set}, log,
|
|
15
|
+
// emitMessage, setStatus, shutdownSignal }
|
|
16
|
+
//
|
|
17
|
+
// `--check` argv mode (doctor / `worca plugin channel --check`): read hello,
|
|
18
|
+
// run validateConfig once, write ONE result line, exit 0.
|
|
19
|
+
|
|
20
|
+
import { createInterface } from 'node:readline';
|
|
21
|
+
import { pathToFileURL } from 'node:url';
|
|
22
|
+
import { encodeFrame, decodeFrame, isValidIncoming, isValidMessage, CONNECTION_STATES } from './channel-protocol.mjs';
|
|
23
|
+
|
|
24
|
+
const CHECK_MODE = process.argv.includes('--check');
|
|
25
|
+
const MAX_LOG_CHARS = 8 * 1024;
|
|
26
|
+
|
|
27
|
+
function writeFrame(frame) {
|
|
28
|
+
try { process.stdout.write(encodeFrame(frame)); return true; }
|
|
29
|
+
catch { return false; /* oversize/broken pipe */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// stdout is protocol-reserved: any stray console.* from plugin code becomes a
|
|
33
|
+
// log frame instead of corrupting the stream (shim-child rule, made survivable
|
|
34
|
+
// for a persistent process).
|
|
35
|
+
function shimConsole() {
|
|
36
|
+
const to = (level) => (...args) => {
|
|
37
|
+
const msg = args.map((a) => (typeof a === 'string' ? a : (() => { try { return JSON.stringify(a); } catch { return String(a); } })())).join(' ');
|
|
38
|
+
writeFrame({ type: 'log', level, msg: msg.slice(0, MAX_LOG_CHARS) });
|
|
39
|
+
};
|
|
40
|
+
console.log = to('info');
|
|
41
|
+
console.info = to('info');
|
|
42
|
+
console.warn = to('warn');
|
|
43
|
+
console.error = to('error');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function errKind(err) {
|
|
47
|
+
const kinds = ['auth', 'rate-limit', 'network', 'plugin', 'timeout', 'protocol'];
|
|
48
|
+
return kinds.includes(err?.kind) ? err.kind : 'plugin';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function main() {
|
|
52
|
+
process.stdin.setEncoding('utf8');
|
|
53
|
+
const lines = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
54
|
+
const iter = lines[Symbol.asyncIterator]();
|
|
55
|
+
|
|
56
|
+
const first = await iter.next();
|
|
57
|
+
if (first.done) process.exit(1);
|
|
58
|
+
const hello = decodeFrame(first.value);
|
|
59
|
+
if (!hello || hello.type !== 'hello') {
|
|
60
|
+
process.stderr.write('channel-worker-child: first frame must be a valid hello\n');
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const config = { ...hello.config };
|
|
65
|
+
const stateSnapshot = { ...hello.state };
|
|
66
|
+
const shutdownController = new AbortController();
|
|
67
|
+
|
|
68
|
+
const ctx = {
|
|
69
|
+
apiVersion: hello.apiVersion,
|
|
70
|
+
platform: hello.platform,
|
|
71
|
+
mock: hello.mock === true,
|
|
72
|
+
config,
|
|
73
|
+
state: {
|
|
74
|
+
// get() prefers writes made this session; set() RECORDS and emits — the
|
|
75
|
+
// HOST persists via writePluginState (shim stateDelta semantics).
|
|
76
|
+
get: async (k) => (stateSnapshot[k] ?? null),
|
|
77
|
+
set: async (k, v) => {
|
|
78
|
+
stateSnapshot[k] = v;
|
|
79
|
+
if (!writeFrame({ type: 'state-delta', delta: { [k]: v } })) {
|
|
80
|
+
writeFrame({ type: 'log', level: 'warn', msg: `state-delta for "${k}" exceeds the 1 MiB frame cap — not persisted` });
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
log: (level, msg) => writeFrame({ type: 'log', level: String(level || 'info'), msg: String(msg).slice(0, MAX_LOG_CHARS) }),
|
|
85
|
+
emitMessage: (msg) => {
|
|
86
|
+
if (!isValidIncoming(msg)) { ctx.log('warn', 'emitMessage: invalid IncomingMessage dropped'); return; }
|
|
87
|
+
writeFrame({ type: 'inbound', msg: { chatId: msg.chatId, userId: msg.userId, text: msg.text, meta: msg.meta ?? {} } });
|
|
88
|
+
},
|
|
89
|
+
setStatus: (state, detail) => {
|
|
90
|
+
if (!CONNECTION_STATES.includes(state)) return;
|
|
91
|
+
writeFrame({ type: 'status', state, detail: detail == null ? null : String(detail) });
|
|
92
|
+
},
|
|
93
|
+
shutdownSignal: shutdownController.signal,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
shimConsole();
|
|
97
|
+
|
|
98
|
+
const mod = await import(pathToFileURL(hello.module).href);
|
|
99
|
+
|
|
100
|
+
if (CHECK_MODE) {
|
|
101
|
+
let result;
|
|
102
|
+
try {
|
|
103
|
+
result = typeof mod.validateConfig === 'function'
|
|
104
|
+
? await mod.validateConfig(config)
|
|
105
|
+
: { ok: true, skipped: 'plugin exports no validateConfig' };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
result = { ok: false, errors: [{ field: null, message: err?.message || String(err) }] };
|
|
108
|
+
}
|
|
109
|
+
process.stdout.write(`${JSON.stringify(result ?? { ok: false, errors: [{ field: null, message: 'validateConfig returned nothing' }] })}\n`, () => process.exit(0));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (typeof mod.createChannelWorker !== 'function') {
|
|
114
|
+
process.stderr.write(`channel module has no createChannelWorker export: ${hello.module}\n`);
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
const worker = mod.createChannelWorker(ctx);
|
|
118
|
+
|
|
119
|
+
let shuttingDown = false;
|
|
120
|
+
const shutdown = async () => {
|
|
121
|
+
if (shuttingDown) return;
|
|
122
|
+
shuttingDown = true;
|
|
123
|
+
shutdownController.abort();
|
|
124
|
+
try { await worker.stop?.(); } catch { /* best effort */ }
|
|
125
|
+
process.exit(0);
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// start() owns the platform connection; ready (with optional identity, e.g.
|
|
129
|
+
// '@worca_bot' from getMe) is emitted by the CHILD once start resolves.
|
|
130
|
+
// A start() failure is a crash: nonzero exit -> supervisor backoff, and auth
|
|
131
|
+
// failures should setStatus('disconnected', ...) + return instead of throwing
|
|
132
|
+
// when a restart cannot help.
|
|
133
|
+
let startInfo;
|
|
134
|
+
try {
|
|
135
|
+
startInfo = await worker.start?.();
|
|
136
|
+
} catch (err) {
|
|
137
|
+
process.stderr.write(`worker start failed: ${err?.message || err}\n`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
writeFrame({ type: 'ready', identity: startInfo?.identity ?? null });
|
|
141
|
+
|
|
142
|
+
for await (const line of lines) {
|
|
143
|
+
const frame = decodeFrame(line);
|
|
144
|
+
if (!frame) { ctx.log('warn', 'invalid host frame dropped'); continue; }
|
|
145
|
+
switch (frame.type) {
|
|
146
|
+
case 'send': {
|
|
147
|
+
(async () => {
|
|
148
|
+
try {
|
|
149
|
+
if (!isValidMessage(frame.message)) throw Object.assign(new Error('invalid NormalizedMessage'), { kind: 'protocol' });
|
|
150
|
+
const r = await worker.send(frame.chatId, frame.message);
|
|
151
|
+
writeFrame({ type: 'send-result', id: frame.id, ok: true, ...(r && typeof r === 'object' ? { detail: r } : {}) });
|
|
152
|
+
} catch (err) {
|
|
153
|
+
writeFrame({
|
|
154
|
+
type: 'send-result', id: frame.id, ok: false,
|
|
155
|
+
error: {
|
|
156
|
+
kind: errKind(err), message: err?.message || String(err),
|
|
157
|
+
...(Number.isFinite(err?.retryAfterMs) ? { retryAfterMs: err.retryAfterMs } : {}),
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
})();
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
case 'webhook': {
|
|
165
|
+
(async () => {
|
|
166
|
+
try {
|
|
167
|
+
if (typeof worker.handleWebhook !== 'function') {
|
|
168
|
+
writeFrame({ type: 'webhook-result', id: frame.id, statusCode: 501 });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
const res = await worker.handleWebhook({ method: frame.method, path: frame.path, headers: frame.headers, bodyB64: frame.bodyB64 });
|
|
172
|
+
writeFrame({
|
|
173
|
+
type: 'webhook-result', id: frame.id,
|
|
174
|
+
statusCode: Number.isInteger(res?.statusCode) ? res.statusCode : 200,
|
|
175
|
+
...(res?.headers ? { headers: res.headers } : {}),
|
|
176
|
+
...(typeof res?.bodyB64 === 'string' ? { bodyB64: res.bodyB64 } : {}),
|
|
177
|
+
});
|
|
178
|
+
} catch (err) {
|
|
179
|
+
ctx.log('error', `handleWebhook failed: ${err?.message || err}`);
|
|
180
|
+
writeFrame({ type: 'webhook-result', id: frame.id, statusCode: 500 });
|
|
181
|
+
}
|
|
182
|
+
})();
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case 'config': {
|
|
186
|
+
Object.keys(config).forEach((k) => { if (!(k in frame.config)) delete config[k]; });
|
|
187
|
+
Object.assign(config, frame.config);
|
|
188
|
+
try { await worker.onConfig?.(config); } catch (err) { ctx.log('warn', `onConfig failed: ${err?.message || err}`); }
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case 'ping':
|
|
192
|
+
writeFrame({ type: 'pong', id: frame.id });
|
|
193
|
+
break;
|
|
194
|
+
case 'shutdown':
|
|
195
|
+
await shutdown();
|
|
196
|
+
break;
|
|
197
|
+
case 'hello':
|
|
198
|
+
ctx.log('warn', 'duplicate hello ignored');
|
|
199
|
+
break;
|
|
200
|
+
default:
|
|
201
|
+
ctx.log('warn', `unexpected host frame "${frame.type}" ignored`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// stdin EOF = host died or closed us: treat as shutdown.
|
|
205
|
+
await shutdown();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
main().catch((err) => {
|
|
209
|
+
process.stderr.write(`channel-worker-child crashed: ${err?.stack || err}\n`);
|
|
210
|
+
process.exit(1);
|
|
211
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// src/core/chat/chat-context.mjs
|
|
2
|
+
// Per-chat persisted state, ported from pre-1.0 integrations/chat_context.js.
|
|
3
|
+
// Keys are "platform:chatId"; state is {active_project, mute_until,
|
|
4
|
+
// muted_messages}. Atomic tmp+rename writes. Default storage lives at
|
|
5
|
+
// worcaHome()/chat-context.json (callers may inject a path for tests).
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { worcaHome } from '../projects.mjs';
|
|
10
|
+
|
|
11
|
+
const SCHEMA_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
const DEFAULT_CHAT_STATE = {
|
|
14
|
+
active_project: null,
|
|
15
|
+
mute_until: null,
|
|
16
|
+
muted_messages: 0,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function chatContextFile() {
|
|
20
|
+
return join(worcaHome(), 'chat-context.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string} [filePath] absolute path (defaults to worcaHome()/chat-context.json)
|
|
25
|
+
* @returns {{ get, set, isMuted, incrementMuted }}
|
|
26
|
+
*/
|
|
27
|
+
export function createChatContext(filePath = chatContextFile()) {
|
|
28
|
+
const data = load(filePath);
|
|
29
|
+
|
|
30
|
+
function get(chatKey) {
|
|
31
|
+
return { ...DEFAULT_CHAT_STATE, ...data.chats[chatKey] };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function set(chatKey, patch) {
|
|
35
|
+
data.chats[chatKey] = { ...DEFAULT_CHAT_STATE, ...data.chats[chatKey], ...patch };
|
|
36
|
+
save(filePath, data);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isMuted(chatKey) {
|
|
40
|
+
const { mute_until } = get(chatKey);
|
|
41
|
+
if (!mute_until) return false;
|
|
42
|
+
return new Date(mute_until) > new Date();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function incrementMuted(chatKey) {
|
|
46
|
+
const current = get(chatKey);
|
|
47
|
+
set(chatKey, { muted_messages: current.muted_messages + 1 });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { get, set, isMuted, incrementMuted };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function load(filePath) {
|
|
54
|
+
try {
|
|
55
|
+
const raw = JSON.parse(readFileSync(filePath, 'utf8'));
|
|
56
|
+
if (raw && typeof raw === 'object' && raw.chats && typeof raw.chats === 'object') return raw;
|
|
57
|
+
} catch { /* missing or invalid — start fresh */ }
|
|
58
|
+
return { schema_version: SCHEMA_VERSION, chats: {} };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function save(filePath, data) {
|
|
62
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
63
|
+
const tmp = `${filePath}.tmp`;
|
|
64
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
65
|
+
renameSync(tmp, filePath);
|
|
66
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// src/core/chat/command-router.mjs
|
|
2
|
+
// Inbound chat commands -> worca actions (chat-connectivity-design.md §4.6).
|
|
3
|
+
// Command surface ported from the pre-1.0 commands/{global,project,control}.js
|
|
4
|
+
// re-targeted to 1.0 (live runs Map + pipelines DB), plus the NEW approval
|
|
5
|
+
// surface (/approve, /retry, /abort, /answer) the old code lacked. Fleet and
|
|
6
|
+
// workspace command families are not ported (no 1.0 counterpart).
|
|
7
|
+
//
|
|
8
|
+
// Everything with policy weight happens HERE, host-side: allowlist
|
|
9
|
+
// (deny-by-default), parsing, run resolution, answer-payload mapping. The
|
|
10
|
+
// `actions` capability object is injected by ui/server.mjs over its runs Map;
|
|
11
|
+
// this module never imports Express or the orchestrator.
|
|
12
|
+
|
|
13
|
+
import { parseCommand } from './parser.mjs';
|
|
14
|
+
import { createAllowlistGuard, parseIdList } from './allowlist.mjs';
|
|
15
|
+
import { runRef, fmtUsd, fmtMs } from './renderers.mjs';
|
|
16
|
+
|
|
17
|
+
const md = (value) => ({ kind: 'markdown', value });
|
|
18
|
+
const reply = (text, severity = 'info') => ({ title: null, body: [md(text)], severity });
|
|
19
|
+
|
|
20
|
+
/** 1.0 pipeline statuses -> emoji (statusEmoji port, re-keyed). */
|
|
21
|
+
export function statusEmoji(status) {
|
|
22
|
+
switch (String(status || '')) {
|
|
23
|
+
case 'running': case 'starting': return '🟢';
|
|
24
|
+
case 'done': return '✅';
|
|
25
|
+
case 'error': return '🔴';
|
|
26
|
+
case 'stopped': return '⏹';
|
|
27
|
+
case 'paused': case 'pausing': case 'interrupted': return '⏸';
|
|
28
|
+
default: return '⚪';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** "30m" | "2h" | "1d" -> ms (parseDuration port). null on anything else. */
|
|
33
|
+
export function parseDuration(str) {
|
|
34
|
+
const m = /^(\d+)([mhd])$/.exec(String(str || '').trim());
|
|
35
|
+
if (!m) return null;
|
|
36
|
+
const n = Number(m[1]);
|
|
37
|
+
return m[2] === 'm' ? n * 60000 : m[2] === 'h' ? n * 3600000 : n * 86400000;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const HELP_TEXT = [
|
|
41
|
+
'**worca-cc chat commands**',
|
|
42
|
+
'`/runs` — live runs · `/last` — latest finished pipeline',
|
|
43
|
+
'`/status [*ref]` — run detail · `/cost [*ref]` — run cost',
|
|
44
|
+
'`/pause [*ref]` · `/stop [*ref]` · `/resume [*ref]`',
|
|
45
|
+
'`/approve [*ref]` — continue past a gate · `/retry [*ref]` — another cycle',
|
|
46
|
+
'`/abort [*ref]` — abort a recovery prompt',
|
|
47
|
+
'`/answer [*ref] <n|text> [| …]` — answer clarify questions (option number, or text for free-text)',
|
|
48
|
+
'`/projects` · `/use <name>` — scope commands to one project',
|
|
49
|
+
'`/mute 30m|2h|1d` · `/unmute` — silence notifications for this chat',
|
|
50
|
+
'`/whoami` · `/help`',
|
|
51
|
+
'_`*ref` is a run-id suffix, e.g. `*2951`; omit it when only one run is live._',
|
|
52
|
+
].join('\n');
|
|
53
|
+
|
|
54
|
+
const LIVE = new Set(['running', 'starting', 'pausing']);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve which run a command targets (resolveRunId port: wildcard suffix,
|
|
58
|
+
* disambiguation, no-arg single-active default).
|
|
59
|
+
* @returns {{run?:object, row?:object, error?:object}} run = live entry summary,
|
|
60
|
+
* row = history row (when not live); error = NormalizedMessage reply
|
|
61
|
+
*/
|
|
62
|
+
function resolveTarget(arg, live, rows, { wantLive = false } = {}) {
|
|
63
|
+
// wantLive commands (/pause /stop /approve /answer…) must never bind to a
|
|
64
|
+
// finished entry still parked in the runs Map.
|
|
65
|
+
if (wantLive) live = live.filter((r) => LIVE.has(String(r.status || '')));
|
|
66
|
+
const suffix = String(arg || '').replace(/^\*/, '').trim();
|
|
67
|
+
if (!suffix) {
|
|
68
|
+
const active = live.filter((r) => LIVE.has(String(r.status || '')));
|
|
69
|
+
const pool = active.length ? active : live;
|
|
70
|
+
if (pool.length === 1) return { run: pool[0] };
|
|
71
|
+
if (pool.length === 0) return { error: reply('No live runs. `/runs` lists them, `/last` shows the latest finished one.', 'warning') };
|
|
72
|
+
return { error: disambiguate(pool.map((r) => ({ id: r.runId, title: r.title, status: r.status }))) };
|
|
73
|
+
}
|
|
74
|
+
const liveHits = live.filter((r) => String(r.runId).endsWith(suffix) || String(r.pipelineId || '').endsWith(suffix));
|
|
75
|
+
if (liveHits.length === 1) return { run: liveHits[0] };
|
|
76
|
+
if (liveHits.length > 1) return { error: disambiguate(liveHits.map((r) => ({ id: r.runId, title: r.title, status: r.status }))) };
|
|
77
|
+
if (wantLive) return { error: reply(`No live run matches \`*${suffix}\`.`, 'warning') };
|
|
78
|
+
const rowHits = (rows || []).filter((r) => String(r.id).endsWith(suffix));
|
|
79
|
+
if (rowHits.length === 1) return { row: rowHits[0] };
|
|
80
|
+
if (rowHits.length > 1) return { error: disambiguate(rowHits.map((r) => ({ id: r.id, title: r.title, status: r.status }))) };
|
|
81
|
+
return { error: reply(`No run matches \`*${suffix}\`.`, 'warning') };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function disambiguate(candidates) {
|
|
85
|
+
const lines = ['Ambiguous — use a longer suffix:'];
|
|
86
|
+
for (const c of candidates.slice(0, 6)) {
|
|
87
|
+
lines.push(`• ${statusEmoji(c.status)} \`${runRef(c.id)}\` ${String(c.title || '').slice(0, 50)}`);
|
|
88
|
+
}
|
|
89
|
+
return reply(lines.join('\n'), 'warning');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function runLine(r) {
|
|
93
|
+
const title = String(r.title || '(untitled)').slice(0, 60);
|
|
94
|
+
return `${statusEmoji(r.status)} \`${runRef(r.runId || r.id)}\` ${r.status} — ${title}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* @param {{actions:object, chatContext:object, logger?:(l:string,m:string)=>void}} deps
|
|
99
|
+
* actions: listRuns(), runState(runId), pendingQuestion(runId),
|
|
100
|
+
* answer(runId, id, payload), stop(runId), pause(runId),
|
|
101
|
+
* resume(pipelineId), history({limit}), listProjects()
|
|
102
|
+
*/
|
|
103
|
+
export function createCommandRouter({ actions, chatContext, logger = () => {} }) {
|
|
104
|
+
const projectOf = (chatKey) => chatContext.get(chatKey).active_project;
|
|
105
|
+
|
|
106
|
+
const scopedRuns = (chatKey) => {
|
|
107
|
+
const all = actions.listRuns().filter((r) => (r.kind || 'run') === 'run' || r.kind === 'workspace-run');
|
|
108
|
+
const scope = projectOf(chatKey);
|
|
109
|
+
if (!scope) return all;
|
|
110
|
+
return all.filter((r) => String(r.projectDir || '').split('/').pop() === scope
|
|
111
|
+
|| (r.projectNames || []).includes(scope));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const handlers = {
|
|
115
|
+
start: async () => reply(HELP_TEXT),
|
|
116
|
+
help: async () => reply(HELP_TEXT),
|
|
117
|
+
|
|
118
|
+
whoami: async ({ chatKey, platform, msg }) =>
|
|
119
|
+
reply(`platform: **${platform}** · chat: \`${msg.chatId}\` · key: \`${chatKey}\`\nThis chat is allow-listed for commands.`),
|
|
120
|
+
|
|
121
|
+
projects: async () => {
|
|
122
|
+
const projects = await actions.listProjects();
|
|
123
|
+
if (!projects.length) return reply('No projects onboarded.');
|
|
124
|
+
return reply(['**Projects:**', ...projects.map((p) => `• ${p.name}`)].join('\n'));
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
use: async ({ chatKey, args }) => {
|
|
128
|
+
if (!args[0]) {
|
|
129
|
+
const cur = projectOf(chatKey);
|
|
130
|
+
return reply(cur ? `Active project: **${cur}** (\`/use -\` to clear)` : 'No active project — commands see all runs. `/use <name>` to scope.');
|
|
131
|
+
}
|
|
132
|
+
if (args[0] === '-') {
|
|
133
|
+
chatContext.set(chatKey, { active_project: null });
|
|
134
|
+
return reply('Cleared — commands see all runs.');
|
|
135
|
+
}
|
|
136
|
+
const projects = await actions.listProjects();
|
|
137
|
+
const hit = projects.find((p) => p.name === args[0]);
|
|
138
|
+
if (!hit) return reply(`Unknown project "${args[0]}". \`/projects\` lists them.`, 'warning');
|
|
139
|
+
chatContext.set(chatKey, { active_project: hit.name });
|
|
140
|
+
return reply(`Active project: **${hit.name}** — /runs, /status now scope to it.`);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
runs: async ({ chatKey }) => {
|
|
144
|
+
const live = scopedRuns(chatKey);
|
|
145
|
+
if (!live.length) return reply('No live runs. `/last` shows the latest finished pipeline.');
|
|
146
|
+
return reply(['**Live runs:**', ...live.map(runLine)].join('\n'));
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
last: async () => {
|
|
150
|
+
const rows = await actions.history({ limit: 1 });
|
|
151
|
+
if (!rows.length) return reply('No pipelines yet.');
|
|
152
|
+
const r = rows[0];
|
|
153
|
+
const bits = [runLine({ ...r, runId: r.id })];
|
|
154
|
+
const cost = fmtUsd(r.totalCostUsd);
|
|
155
|
+
if (cost) bits.push(` **Cost:** ${cost}`);
|
|
156
|
+
const dur = fmtMs(r.totalActiveMs);
|
|
157
|
+
if (dur) bits.push(` **Active:** ${dur}`);
|
|
158
|
+
return reply(bits.join('\n'));
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
status: async ({ chatKey, args }) => {
|
|
162
|
+
const t = resolveTarget(args[0], scopedRuns(chatKey), await actions.history({ limit: 50 }));
|
|
163
|
+
if (t.error) return t.error;
|
|
164
|
+
if (t.row) {
|
|
165
|
+
const r = t.row;
|
|
166
|
+
return reply([runLine({ ...r, runId: r.id }),
|
|
167
|
+
...(fmtUsd(r.totalCostUsd) ? [` **Cost:** ${fmtUsd(r.totalCostUsd)}`] : []),
|
|
168
|
+
...(r.pauseReason ? [` **Pause reason:** ${r.pauseReason}`] : []),
|
|
169
|
+
].join('\n'));
|
|
170
|
+
}
|
|
171
|
+
const r = t.run;
|
|
172
|
+
const lines = [runLine(r)];
|
|
173
|
+
const state = actions.runState(r.runId);
|
|
174
|
+
if (state) {
|
|
175
|
+
const doneSteps = (state.steps || []).filter((s) => s.status === 'done').length;
|
|
176
|
+
lines.push(` **Steps:** ${doneSteps}/${(state.steps || []).length} done · **Phase:** ${state.phase || '—'}`);
|
|
177
|
+
const cost = fmtUsd(state.totalCostUsd);
|
|
178
|
+
if (cost) lines.push(` **Cost:** ${cost}`);
|
|
179
|
+
}
|
|
180
|
+
const pq = actions.pendingQuestion(r.runId);
|
|
181
|
+
if (pq) lines.push(` ❓ waiting on you — \`/approve ${runRef(r.runId)}\` or \`/answer ${runRef(r.runId)} <n>\``);
|
|
182
|
+
return reply(lines.join('\n'));
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
cost: async ({ chatKey, args }) => {
|
|
186
|
+
const t = resolveTarget(args[0], scopedRuns(chatKey), await actions.history({ limit: 50 }));
|
|
187
|
+
if (t.error) return t.error;
|
|
188
|
+
if (t.row) return reply(`\`${runRef(t.row.id)}\` cost: ${fmtUsd(t.row.totalCostUsd) || '$0.00'}`);
|
|
189
|
+
const state = actions.runState(t.run.runId);
|
|
190
|
+
return reply(`\`${runRef(t.run.runId)}\` cost so far: ${fmtUsd(state?.totalCostUsd) || '$0.00'}`);
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
pause: async ({ chatKey, args }) => {
|
|
194
|
+
const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
|
|
195
|
+
if (t.error) return t.error;
|
|
196
|
+
await actions.pause(t.run.runId);
|
|
197
|
+
return reply(`⏸ Pausing \`${runRef(t.run.runId)}\` — resume from the UI or \`/resume ${runRef(t.run.runId)}\`.`, 'warning');
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
stop: async ({ chatKey, args }) => {
|
|
201
|
+
const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
|
|
202
|
+
if (t.error) return t.error;
|
|
203
|
+
await actions.stop(t.run.runId);
|
|
204
|
+
return reply(`⏹ Stopping \`${runRef(t.run.runId)}\` (${String(t.run.title || '').slice(0, 50)}).`, 'warning');
|
|
205
|
+
},
|
|
206
|
+
|
|
207
|
+
resume: async ({ args }) => {
|
|
208
|
+
// Resolve against PAUSED/INTERRUPTED history rows (resume works across
|
|
209
|
+
// restarts); a live match means it's already running.
|
|
210
|
+
const rows = (await actions.history({ limit: 50 })).filter((r) => r.status === 'paused' || r.status === 'interrupted');
|
|
211
|
+
let t = resolveTarget(args[0], [], rows);
|
|
212
|
+
if (t.error) {
|
|
213
|
+
if (!args[0] && rows.length > 1) {
|
|
214
|
+
return disambiguate(rows.map((r) => ({ id: r.id, title: r.title, status: r.status })));
|
|
215
|
+
}
|
|
216
|
+
if (!args[0] && !rows.length) return reply('Nothing is paused.', 'warning');
|
|
217
|
+
if (args[0] || rows.length !== 1) return t.error;
|
|
218
|
+
t = { row: rows[0] }; // exactly one paused row, bare /resume: take it
|
|
219
|
+
}
|
|
220
|
+
const out = await actions.resume(t.row.id);
|
|
221
|
+
if (out?.ok) return reply(`▶️ Resuming \`${runRef(t.row.id)}\` — ${String(t.row.title || '').slice(0, 50)}`);
|
|
222
|
+
return reply(`Could not resume \`${runRef(t.row.id)}\`: ${out?.error || 'unknown error'}`, 'error');
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
approve: async (env) => answerDecision(env, 'approve'),
|
|
226
|
+
retry: async (env) => answerDecision(env, 'retry'),
|
|
227
|
+
abort: async (env) => answerDecision(env, 'abort'),
|
|
228
|
+
|
|
229
|
+
answer: async ({ chatKey, args }) => {
|
|
230
|
+
const t = resolveTarget(args[0] && args[0].startsWith('*') ? args[0] : '', scopedRuns(chatKey), [], { wantLive: true });
|
|
231
|
+
if (t.error) return t.error;
|
|
232
|
+
const pq = actions.pendingQuestion(t.run.runId);
|
|
233
|
+
if (!pq) return reply(`\`${runRef(t.run.runId)}\` is not waiting on a question.`, 'warning');
|
|
234
|
+
if (pq.kind !== 'clarify' && pq.kind !== 'questions') {
|
|
235
|
+
return reply(`\`${runRef(t.run.runId)}\` is waiting on ${pq.kind} — use \`/approve\` or \`/retry\`.`, 'warning');
|
|
236
|
+
}
|
|
237
|
+
const questions = Array.isArray(pq.questions) ? pq.questions : [];
|
|
238
|
+
const hasRef = !!(args[0] && args[0].startsWith('*'));
|
|
239
|
+
const rest = (hasRef ? args.slice(1) : args).join(' ').trim();
|
|
240
|
+
const ref = runRef(t.run.runId);
|
|
241
|
+
const usage = () => reply(
|
|
242
|
+
`Need ${questions.length} answer${questions.length === 1 ? '' : 's'} — option numbers, or text for free-text questions, separated by \`|\`.\nExample: \`/answer ${ref} ${questions.map((q) => (q.options?.length ? '1' : '<your answer>')).join(' | ')}\``,
|
|
243
|
+
'warning');
|
|
244
|
+
if (!rest) return usage();
|
|
245
|
+
// Parsing spec:
|
|
246
|
+
// 1-question: the whole rest IS the answer — never split (pipes are data).
|
|
247
|
+
// Pure-ordinal back-compat: "1 2 3" iff every question has options and no '|'.
|
|
248
|
+
// Otherwise: pipe-separated, one part per question, in order.
|
|
249
|
+
let parts;
|
|
250
|
+
if (questions.length === 1) {
|
|
251
|
+
parts = [rest];
|
|
252
|
+
} else {
|
|
253
|
+
const tokens = rest.split(/\s+/);
|
|
254
|
+
const allOrdinals = tokens.every((tk) => /^\d+$/.test(tk));
|
|
255
|
+
const everyHasOptions = questions.every((q) => (q.options || []).length > 0);
|
|
256
|
+
parts = allOrdinals && everyHasOptions && !rest.includes('|')
|
|
257
|
+
? tokens
|
|
258
|
+
: rest.split('|').map((s) => s.trim());
|
|
259
|
+
}
|
|
260
|
+
if (parts.length !== questions.length) return usage();
|
|
261
|
+
const answers = [];
|
|
262
|
+
for (let i = 0; i < questions.length; i++) {
|
|
263
|
+
const q = questions[i];
|
|
264
|
+
const opts = Array.isArray(q.options) ? q.options : [];
|
|
265
|
+
const part = parts[i];
|
|
266
|
+
const n = /^\d+$/.test(part) ? Number(part) : null;
|
|
267
|
+
if (opts.length && n !== null) {
|
|
268
|
+
if (n < 1 || n > opts.length) return reply(`Q${i + 1} has options 1–${opts.length}; got ${n}.`, 'warning');
|
|
269
|
+
answers.push({ id: q.id, choice: opts[n - 1] });
|
|
270
|
+
} else if (q.allowFreeText !== false && part) {
|
|
271
|
+
answers.push({ id: q.id, choice: part }); // free text is a choice string (app.js:3717)
|
|
272
|
+
} else if (!opts.length) {
|
|
273
|
+
return reply(`Q${i + 1} needs a written answer — got nothing.`, 'warning');
|
|
274
|
+
} else {
|
|
275
|
+
return reply(`Q${i + 1} takes an option number (1–${opts.length}), not text.`, 'warning');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
await actions.answer(t.run.runId, pq.id, { answers });
|
|
279
|
+
return reply(`✅ Answered ${questions.length} question${questions.length === 1 ? '' : 's'} on \`${ref}\`.`, 'success');
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
mute: async ({ chatKey, args }) => {
|
|
283
|
+
const ms = parseDuration(args[0]);
|
|
284
|
+
if (!ms) return reply('Usage: `/mute 30m` · `/mute 2h` · `/mute 1d`', 'warning');
|
|
285
|
+
const until = new Date(Date.now() + ms).toISOString();
|
|
286
|
+
chatContext.set(chatKey, { mute_until: until });
|
|
287
|
+
return reply(`🔇 Notifications muted for ${args[0]} (commands still work). \`/unmute\` to lift.`);
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
unmute: async ({ chatKey }) => {
|
|
291
|
+
const { muted_messages: muted } = chatContext.get(chatKey);
|
|
292
|
+
chatContext.set(chatKey, { mute_until: null, muted_messages: 0 });
|
|
293
|
+
return reply(`🔔 Notifications back on${muted ? ` (${muted} suppressed while muted)` : ''}.`);
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
async function answerDecision({ chatKey, args }, verb) {
|
|
298
|
+
const t = resolveTarget(args[0], scopedRuns(chatKey), [], { wantLive: true });
|
|
299
|
+
if (t.error) return t.error;
|
|
300
|
+
const pq = actions.pendingQuestion(t.run.runId);
|
|
301
|
+
if (!pq) return reply(`\`${runRef(t.run.runId)}\` is not waiting on a decision.`, 'warning');
|
|
302
|
+
const ref = runRef(t.run.runId);
|
|
303
|
+
let payload;
|
|
304
|
+
if (pq.kind === 'gate') {
|
|
305
|
+
if (verb === 'abort') return reply(`Gates have no abort — \`/approve ${ref}\`, \`/retry ${ref}\`, or \`/stop ${ref}\`.`, 'warning');
|
|
306
|
+
payload = { decision: verb === 'approve' ? 'continue' : 'another' };
|
|
307
|
+
} else if (pq.kind === 'recovery') {
|
|
308
|
+
payload = { decision: verb === 'abort' ? 'abort' : 'retry' };
|
|
309
|
+
} else {
|
|
310
|
+
return reply(`\`${ref}\` is waiting on ${pq.kind} — use \`/answer ${ref} <n>\`.`, 'warning');
|
|
311
|
+
}
|
|
312
|
+
await actions.answer(t.run.runId, pq.id, payload);
|
|
313
|
+
const what = pq.kind === 'gate'
|
|
314
|
+
? (payload.decision === 'continue' ? 'approved — continuing' : 'sent back for another cycle')
|
|
315
|
+
: (payload.decision === 'retry' ? 'retrying' : 'aborting');
|
|
316
|
+
return reply(`✅ \`${ref}\` ${what}.`, 'success');
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
/**
|
|
321
|
+
* @param {{plugin:string, channelId:string, platform:string,
|
|
322
|
+
* channelConfig:object, msg:{chatId:string,userId:string,text:string,meta?:object}}} ev
|
|
323
|
+
* @returns {Promise<object|null>} NormalizedMessage reply or null (ignore)
|
|
324
|
+
*/
|
|
325
|
+
async handleIncoming({ plugin, channelId, platform, channelConfig, msg }) {
|
|
326
|
+
const guard = createAllowlistGuard(parseIdList(channelConfig?.allowedChatIds), {
|
|
327
|
+
debug: (m) => logger('info', m),
|
|
328
|
+
});
|
|
329
|
+
if (!guard.isAllowed({ platform, chatId: msg.chatId })) return null; // silent, fail closed
|
|
330
|
+
const parsed = parseCommand(msg.text);
|
|
331
|
+
if (!parsed) return null; // non-commands are never interpreted
|
|
332
|
+
const handler = Object.hasOwn(handlers, parsed.command) ? handlers[parsed.command] : null;
|
|
333
|
+
const chatKey = `${platform}:${msg.chatId}`;
|
|
334
|
+
if (!handler) return reply(`Unknown command \`/${parsed.command}\` — \`/help\` lists commands.`, 'warning');
|
|
335
|
+
try {
|
|
336
|
+
return await handler({ chatKey, platform, plugin, channelId, msg, args: parsed.args });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
logger('error', `chat command /${parsed.command} failed: ${err?.message || err}`);
|
|
339
|
+
return reply(`Command failed: ${String(err?.message || err).slice(0, 200)}`, 'error');
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
}
|