@applica-software-guru/sdd-core 1.8.2 → 1.8.3
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/agent/agent-defaults.js +2 -2
- package/dist/agent/agent-defaults.js.map +1 -1
- package/dist/agent/agent-runner.d.ts +17 -0
- package/dist/agent/agent-runner.d.ts.map +1 -1
- package/dist/agent/agent-runner.js +163 -11
- package/dist/agent/agent-runner.js.map +1 -1
- package/dist/agent/worker-daemon.d.ts +12 -0
- package/dist/agent/worker-daemon.d.ts.map +1 -0
- package/dist/agent/worker-daemon.js +220 -0
- package/dist/agent/worker-daemon.js.map +1 -0
- package/dist/git/git.d.ts +6 -0
- package/dist/git/git.d.ts.map +1 -1
- package/dist/git/git.js +60 -0
- package/dist/git/git.js.map +1 -1
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/prompt/draft-prompt-generator.d.ts.map +1 -1
- package/dist/prompt/draft-prompt-generator.js +4 -0
- package/dist/prompt/draft-prompt-generator.js.map +1 -1
- package/dist/prompt/prompt-generator.d.ts.map +1 -1
- package/dist/prompt/prompt-generator.js +5 -0
- package/dist/prompt/prompt-generator.js.map +1 -1
- package/dist/remote/sync-engine.d.ts.map +1 -1
- package/dist/remote/sync-engine.js +1 -0
- package/dist/remote/sync-engine.js.map +1 -1
- package/dist/remote/worker-client.d.ts +22 -0
- package/dist/remote/worker-client.d.ts.map +1 -0
- package/dist/remote/worker-client.js +88 -0
- package/dist/remote/worker-client.js.map +1 -0
- package/dist/remote/worker-types.d.ts +38 -0
- package/dist/remote/worker-types.d.ts.map +1 -0
- package/dist/remote/worker-types.js +3 -0
- package/dist/remote/worker-types.js.map +1 -0
- package/dist/scaffold/templates.generated.d.ts +1 -1
- package/dist/scaffold/templates.generated.d.ts.map +1 -1
- package/dist/scaffold/templates.generated.js +51 -33
- package/dist/scaffold/templates.generated.js.map +1 -1
- package/package.json +1 -1
- package/src/agent/agent-defaults.ts +2 -2
- package/src/agent/agent-runner.ts +184 -12
- package/src/agent/worker-daemon.ts +254 -0
- package/src/git/git.ts +61 -0
- package/src/index.ts +7 -2
- package/src/prompt/draft-prompt-generator.ts +8 -0
- package/src/prompt/prompt-generator.ts +8 -0
- package/src/remote/sync-engine.ts +1 -0
- package/src/remote/worker-client.ts +141 -0
- package/src/remote/worker-types.ts +40 -0
- package/src/scaffold/templates.generated.ts +51 -33
|
@@ -228,13 +228,14 @@ name: sdd-remote
|
|
|
228
228
|
description: >
|
|
229
229
|
Remote sync workflow for Story Driven Development. Use when the user asks
|
|
230
230
|
to update local state from remote changes, process remote drafts, and push
|
|
231
|
-
enriched items back.
|
|
231
|
+
enriched items back. Also applies when running a remote worker job (enrich
|
|
232
|
+
or sync).
|
|
232
233
|
license: MIT
|
|
233
234
|
compatibility: Requires sdd CLI (npm i -g @applica-software-guru/sdd)
|
|
234
235
|
allowed-tools: Bash(sdd:*) Read Glob Grep
|
|
235
236
|
metadata:
|
|
236
237
|
author: applica-software-guru
|
|
237
|
-
version: "1.
|
|
238
|
+
version: "1.1"
|
|
238
239
|
---
|
|
239
240
|
|
|
240
241
|
# SDD Remote - Pull, Enrich, Push
|
|
@@ -244,6 +245,9 @@ metadata:
|
|
|
244
245
|
Use this skill to synchronize local SDD docs with remote updates, enrich draft content,
|
|
245
246
|
and publish the enriched result to remote in active states.
|
|
246
247
|
|
|
248
|
+
This skill also applies when a **remote worker job** is dispatched from SDD Flow, as the
|
|
249
|
+
worker runs these same workflows on behalf of the user.
|
|
250
|
+
|
|
247
251
|
## Detection
|
|
248
252
|
|
|
249
253
|
This workflow applies when:
|
|
@@ -251,79 +255,91 @@ This workflow applies when:
|
|
|
251
255
|
- \`.sdd/config.yaml\` exists in the project root
|
|
252
256
|
- The user asks to update local state from remote, pull pending CRs/bugs/docs,
|
|
253
257
|
enrich drafts, or push pending remote updates
|
|
258
|
+
- A remote worker job prompt instructs you to follow this workflow
|
|
254
259
|
|
|
255
|
-
##
|
|
260
|
+
## Workflows
|
|
261
|
+
|
|
262
|
+
### Enrich Workflow (CR)
|
|
256
263
|
|
|
257
|
-
Follow this sequence
|
|
264
|
+
Follow this sequence to enrich a draft Change Request:
|
|
258
265
|
|
|
259
|
-
1.
|
|
266
|
+
1. Pull remote updates:
|
|
260
267
|
|
|
261
268
|
\`\`\`bash
|
|
262
|
-
|
|
269
|
+
sdd pull --crs-only
|
|
263
270
|
\`\`\`
|
|
264
271
|
|
|
265
|
-
|
|
272
|
+
3. Generate draft TODO list:
|
|
266
273
|
|
|
267
274
|
\`\`\`bash
|
|
268
|
-
sdd
|
|
275
|
+
sdd drafts
|
|
269
276
|
\`\`\`
|
|
270
277
|
|
|
271
|
-
|
|
278
|
+
4. Enrich the draft with technical details, acceptance criteria, edge cases, and
|
|
279
|
+
any relevant information from the project documentation and comments.
|
|
272
280
|
|
|
273
|
-
|
|
281
|
+
5. Transition the enriched CR to pending:
|
|
274
282
|
|
|
275
283
|
\`\`\`bash
|
|
276
|
-
sdd
|
|
284
|
+
sdd mark-drafts-enriched
|
|
277
285
|
\`\`\`
|
|
278
286
|
|
|
279
|
-
|
|
287
|
+
This performs: \`draft → pending\`
|
|
288
|
+
|
|
289
|
+
6. Push the enriched content:
|
|
280
290
|
|
|
281
291
|
\`\`\`bash
|
|
282
|
-
sdd
|
|
283
|
-
sdd pull --crs-only
|
|
284
|
-
sdd pull --bugs-only
|
|
292
|
+
sdd push
|
|
285
293
|
\`\`\`
|
|
286
294
|
|
|
287
|
-
|
|
295
|
+
### Enrich Workflow (Document)
|
|
296
|
+
|
|
297
|
+
Follow this sequence to enrich a document:
|
|
298
|
+
|
|
299
|
+
1. Pull remote updates:
|
|
288
300
|
|
|
289
301
|
\`\`\`bash
|
|
290
|
-
sdd
|
|
302
|
+
sdd pull --docs-only
|
|
291
303
|
\`\`\`
|
|
292
304
|
|
|
293
|
-
|
|
294
|
-
|
|
305
|
+
3. Locate the document file in \`product/\` or \`system/\` and update its content
|
|
306
|
+
with the enriched version.
|
|
295
307
|
|
|
296
|
-
4.
|
|
308
|
+
4. Push the enriched content:
|
|
297
309
|
|
|
298
310
|
\`\`\`bash
|
|
299
|
-
sdd
|
|
311
|
+
sdd push
|
|
300
312
|
\`\`\`
|
|
301
313
|
|
|
302
|
-
|
|
314
|
+
If the document was in \`draft\` status, it will transition to \`new\` on the server.
|
|
315
|
+
|
|
316
|
+
### Sync Workflow (Project-level)
|
|
303
317
|
|
|
304
|
-
|
|
305
|
-
- Change Request: \`draft -> pending\`
|
|
306
|
-
- Bug: \`draft -> open\`
|
|
318
|
+
Follow this sequence for a full project sync (all pending items):
|
|
307
319
|
|
|
308
|
-
|
|
320
|
+
1. Pull the latest specs:
|
|
309
321
|
|
|
310
322
|
\`\`\`bash
|
|
311
|
-
sdd
|
|
323
|
+
sdd pull
|
|
312
324
|
\`\`\`
|
|
313
325
|
|
|
314
|
-
|
|
326
|
+
3. Run the \`sdd\` skill — it handles the full loop: open bugs, pending CRs,
|
|
327
|
+
documentation sync, code implementation, mark-synced, and commit.
|
|
328
|
+
|
|
329
|
+
4. Push:
|
|
315
330
|
|
|
316
331
|
\`\`\`bash
|
|
317
|
-
sdd
|
|
332
|
+
sdd push
|
|
318
333
|
\`\`\`
|
|
319
334
|
|
|
320
335
|
## Rules
|
|
321
336
|
|
|
322
337
|
1. Always check remote configuration before pull/push (\`sdd remote status\`)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
338
|
+
3. Do not use \`sdd push --all\` unless the user explicitly asks for a full reseed
|
|
339
|
+
4. If pull reports conflicts, do not overwrite local files blindly; report conflicts and ask how to proceed
|
|
340
|
+
5. Do not edit files inside \`.sdd/\` manually
|
|
341
|
+
6. Keep status transitions explicit: enrich first, then \`sdd mark-drafts-enriched\`, then push
|
|
342
|
+
7. **Always commit before pushing** when the sync workflow makes code changes
|
|
327
343
|
|
|
328
344
|
## Related commands
|
|
329
345
|
|
|
@@ -332,6 +348,8 @@ sdd remote status
|
|
|
332
348
|
- \`sdd pull\`
|
|
333
349
|
- \`sdd drafts\`
|
|
334
350
|
- \`sdd mark-drafts-enriched\`
|
|
351
|
+
- \`sdd sync\`
|
|
352
|
+
- \`sdd mark-synced\`
|
|
335
353
|
- \`sdd push\`
|
|
336
354
|
`;
|
|
337
355
|
exports.FILE_FORMAT_REFERENCE = `# File Format and Status Lifecycle
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"templates.generated.js","sourceRoot":"","sources":["../../src/scaffold/templates.generated.ts"],"names":[],"mappings":";AAAA,6DAA6D;AAC7D,qEAAqE;;;AAExD,QAAA,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGhC,CAAC;AAEW,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiHnC,CAAC;AAEW,QAAA,wBAAwB,GAAG
|
|
1
|
+
{"version":3,"file":"templates.generated.js","sourceRoot":"","sources":["../../src/scaffold/templates.generated.ts"],"names":[],"mappings":";AAAA,6DAA6D;AAC7D,qEAAqE;;;AAExD,QAAA,iBAAiB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyGhC,CAAC;AAEW,QAAA,oBAAoB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiHnC,CAAC;AAEW,QAAA,wBAAwB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgIvC,CAAC;AAEW,QAAA,qBAAqB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAiEpC,CAAC;AAEW,QAAA,yBAAyB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BxC,CAAC;AAEW,QAAA,cAAc,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA6B7B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const DEFAULT_AGENTS: Record<string, string> = {
|
|
2
|
-
claude: 'claude -p "$(cat $PROMPT_FILE)" --
|
|
3
|
-
codex: 'codex -q "$(cat $PROMPT_FILE)"',
|
|
2
|
+
claude: 'claude -p "$(cat $PROMPT_FILE)" --permission-mode auto --verbose --model $MODEL',
|
|
3
|
+
codex: 'codex -q "$(cat $PROMPT_FILE)" -m $MODEL',
|
|
4
4
|
opencode: 'opencode -p "$(cat $PROMPT_FILE)"',
|
|
5
5
|
};
|
|
6
6
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
1
|
+
import { spawn, type ChildProcess } from 'node:child_process';
|
|
2
2
|
import { writeFile, unlink } from 'node:fs/promises';
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
@@ -9,38 +9,164 @@ export interface AgentRunnerOptions {
|
|
|
9
9
|
root: string;
|
|
10
10
|
prompt: string;
|
|
11
11
|
agent: string;
|
|
12
|
+
model?: string;
|
|
12
13
|
agents?: Record<string, string>;
|
|
13
14
|
onOutput?: (data: string) => void;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
/** Handle returned by startAgent() for interactive worker mode. */
|
|
18
|
+
export interface AgentRunnerHandle {
|
|
19
|
+
/** Resolves with the exit code when the agent process exits. */
|
|
20
|
+
exitPromise: Promise<number>;
|
|
21
|
+
/** Write data to the agent's stdin (for Q&A relay). */
|
|
22
|
+
writeStdin: (data: string) => void;
|
|
23
|
+
/** Kill the agent process. */
|
|
24
|
+
kill: () => void;
|
|
25
|
+
/** Path to the temp prompt file (cleaned up on exit). */
|
|
26
|
+
promptFile: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Extract human-readable text from a single stream-json NDJSON line.
|
|
31
|
+
* Returns the text to emit, or null if the line should be suppressed.
|
|
32
|
+
* Falls back to the raw line if it is not valid JSON (e.g. stderr noise).
|
|
33
|
+
*/
|
|
34
|
+
function extractStreamJsonText(line: string): string | null {
|
|
35
|
+
let event: unknown;
|
|
36
|
+
try {
|
|
37
|
+
event = JSON.parse(line);
|
|
38
|
+
} catch {
|
|
39
|
+
// Not JSON — pass raw (e.g. stderr lines from subprocesses)
|
|
40
|
+
return line;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (typeof event !== 'object' || event === null || !('type' in event)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { type } = event as Record<string, unknown>;
|
|
48
|
+
|
|
49
|
+
if (type === 'assistant') {
|
|
50
|
+
const message = (event as Record<string, unknown>).message;
|
|
51
|
+
if (typeof message === 'object' && message !== null && 'content' in message) {
|
|
52
|
+
const content = (message as Record<string, unknown>).content;
|
|
53
|
+
if (Array.isArray(content)) {
|
|
54
|
+
const texts = content
|
|
55
|
+
.filter((b): b is { type: string; text: string } => b?.type === 'text' && typeof b?.text === 'string')
|
|
56
|
+
.map((b) => b.text);
|
|
57
|
+
return texts.length > 0 ? texts.join('') : null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (type === 'result') {
|
|
64
|
+
const result = (event as Record<string, unknown>).result;
|
|
65
|
+
if (typeof result === 'string' && result.trim()) {
|
|
66
|
+
return result;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Suppress system/init/tool events — not useful for display
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Attach NDJSON-parsing stream handlers to a child process.
|
|
77
|
+
* Each complete JSON line is parsed and the extracted text forwarded to `sink`.
|
|
78
|
+
* stderr lines are forwarded raw (they are not stream-json).
|
|
79
|
+
*/
|
|
80
|
+
function pipeWithNdjsonParsing(
|
|
81
|
+
child: ChildProcess,
|
|
82
|
+
sink: (text: string) => void,
|
|
83
|
+
): void {
|
|
84
|
+
if (child.stdout) {
|
|
85
|
+
let buf = '';
|
|
86
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
87
|
+
buf += data.toString();
|
|
88
|
+
const lines = buf.split('\n');
|
|
89
|
+
buf = lines.pop() ?? '';
|
|
90
|
+
for (const line of lines) {
|
|
91
|
+
if (!line.trim()) continue;
|
|
92
|
+
const text = extractStreamJsonText(line);
|
|
93
|
+
if (text) sink(text);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
child.stdout.on('end', () => {
|
|
97
|
+
if (buf.trim()) {
|
|
98
|
+
const text = extractStreamJsonText(buf);
|
|
99
|
+
if (text) sink(text);
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (child.stderr) {
|
|
105
|
+
child.stderr.on('data', (data: Buffer) => sink(data.toString()));
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function prepareAgent(options: AgentRunnerOptions): Promise<{ command: string; tmpFile: string; isClaudeAgent: boolean }> {
|
|
110
|
+
const { prompt, agent, model, agents } = options;
|
|
18
111
|
|
|
19
112
|
const template = resolveAgentCommand(agent, agents);
|
|
20
113
|
if (!template) {
|
|
21
114
|
throw new Error(`Unknown agent "${agent}". Available: ${Object.keys(agents ?? {}).join(', ') || 'claude, codex, opencode'}`);
|
|
22
115
|
}
|
|
23
116
|
|
|
24
|
-
// Write prompt to temp file (too large for CLI arg)
|
|
25
117
|
const tmpFile = join(tmpdir(), `sdd-prompt-${randomBytes(6).toString('hex')}.md`);
|
|
26
118
|
await writeFile(tmpFile, prompt, 'utf-8');
|
|
27
119
|
|
|
28
|
-
|
|
29
|
-
|
|
120
|
+
let command = template.replace(/\$PROMPT_FILE/g, tmpFile);
|
|
121
|
+
if (model) {
|
|
122
|
+
command = command.replace(/\$MODEL/g, model);
|
|
123
|
+
// If template doesn't have $MODEL but model is specified, inject --model flag
|
|
124
|
+
if (!template.includes('$MODEL')) {
|
|
125
|
+
command = command.replace(/^(\S+)/, `$1 --model ${model}`);
|
|
126
|
+
}
|
|
127
|
+
} else {
|
|
128
|
+
// Remove $MODEL placeholder and surrounding flags if not provided
|
|
129
|
+
command = command.replace(/--model\s+\$MODEL\s*/g, '');
|
|
130
|
+
command = command.replace(/\$MODEL\s*/g, '');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// For the claude CLI: switch to stream-json output for real-time streaming.
|
|
134
|
+
// --verbose is required alongside --output-format stream-json when using -p.
|
|
135
|
+
const isClaudeAgent = command.trimStart().startsWith('claude');
|
|
136
|
+
if (isClaudeAgent) {
|
|
137
|
+
// Ensure --verbose is present (required by stream-json in print mode)
|
|
138
|
+
if (!command.includes('--verbose')) {
|
|
139
|
+
command = command.trimEnd() + ' --verbose';
|
|
140
|
+
}
|
|
141
|
+
command = command.trimEnd() + ' --output-format stream-json';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return { command, tmpFile, isClaudeAgent };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function runAgent(options: AgentRunnerOptions): Promise<number> {
|
|
148
|
+
const { root, onOutput } = options;
|
|
149
|
+
const { command, tmpFile, isClaudeAgent } = await prepareAgent(options);
|
|
30
150
|
|
|
31
151
|
try {
|
|
32
152
|
const exitCode = await new Promise<number>((resolve, reject) => {
|
|
153
|
+
// For claude with stream-json we always pipe stdout so we can parse NDJSON.
|
|
154
|
+
// For other agents we inherit stdio when no onOutput is provided.
|
|
155
|
+
const usesPipe = isClaudeAgent || !!onOutput;
|
|
33
156
|
const child = spawn(command, {
|
|
34
157
|
cwd: root,
|
|
35
158
|
shell: true,
|
|
36
|
-
stdio:
|
|
159
|
+
stdio: usesPipe ? ['inherit', 'pipe', 'pipe'] : 'inherit',
|
|
37
160
|
});
|
|
38
161
|
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
162
|
+
if (usesPipe) {
|
|
163
|
+
const sink = onOutput ?? ((text: string) => process.stdout.write(text));
|
|
164
|
+
if (isClaudeAgent) {
|
|
165
|
+
pipeWithNdjsonParsing(child, sink);
|
|
166
|
+
} else {
|
|
167
|
+
if (child.stdout) child.stdout.on('data', (data: Buffer) => sink(data.toString()));
|
|
168
|
+
if (child.stderr) child.stderr.on('data', (data: Buffer) => sink(data.toString()));
|
|
169
|
+
}
|
|
44
170
|
}
|
|
45
171
|
|
|
46
172
|
child.on('error', reject);
|
|
@@ -52,3 +178,49 @@ export async function runAgent(options: AgentRunnerOptions): Promise<number> {
|
|
|
52
178
|
await unlink(tmpFile).catch(() => {});
|
|
53
179
|
}
|
|
54
180
|
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Start an agent in interactive mode (stdin piped for Q&A).
|
|
184
|
+
* Returns a handle for writing to stdin, killing the process, and awaiting exit.
|
|
185
|
+
*/
|
|
186
|
+
export async function startAgent(options: AgentRunnerOptions): Promise<AgentRunnerHandle> {
|
|
187
|
+
const { root, onOutput } = options;
|
|
188
|
+
const { command, tmpFile, isClaudeAgent } = await prepareAgent(options);
|
|
189
|
+
|
|
190
|
+
const child: ChildProcess = spawn(command, {
|
|
191
|
+
cwd: root,
|
|
192
|
+
shell: true,
|
|
193
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const sink = onOutput ?? ((text: string) => process.stdout.write(text));
|
|
197
|
+
if (isClaudeAgent) {
|
|
198
|
+
pipeWithNdjsonParsing(child, sink);
|
|
199
|
+
} else {
|
|
200
|
+
if (child.stdout) child.stdout.on('data', (data: Buffer) => sink(data.toString()));
|
|
201
|
+
if (child.stderr) child.stderr.on('data', (data: Buffer) => sink(data.toString()));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const exitPromise = new Promise<number>((resolve, reject) => {
|
|
205
|
+
child.on('error', reject);
|
|
206
|
+
child.on('close', (code) => {
|
|
207
|
+
unlink(tmpFile).catch(() => {});
|
|
208
|
+
resolve(code ?? 1);
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
exitPromise,
|
|
214
|
+
writeStdin: (data: string) => {
|
|
215
|
+
if (child.stdin && !child.stdin.destroyed) {
|
|
216
|
+
child.stdin.write(data);
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
kill: () => {
|
|
220
|
+
if (!child.killed) {
|
|
221
|
+
child.kill('SIGTERM');
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
promptFile: tmpFile,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import type { ApiClientConfig } from '../remote/api-client.js';
|
|
5
|
+
import type { WorkerState, WorkerJobAssignment } from '../remote/worker-types.js';
|
|
6
|
+
import {
|
|
7
|
+
registerWorker,
|
|
8
|
+
workerHeartbeat,
|
|
9
|
+
workerPoll,
|
|
10
|
+
workerJobStarted,
|
|
11
|
+
workerJobOutput,
|
|
12
|
+
workerJobQuestion,
|
|
13
|
+
workerJobAnswers,
|
|
14
|
+
workerJobCompleted,
|
|
15
|
+
} from '../remote/worker-client.js';
|
|
16
|
+
import { startAgent } from './agent-runner.js';
|
|
17
|
+
import { checkoutBranch, getCurrentBranch, getCurrentCommit, getJobChangedFiles } from '../git/git.js';
|
|
18
|
+
|
|
19
|
+
export interface WorkerDaemonOptions {
|
|
20
|
+
root: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
agent: string;
|
|
23
|
+
agents?: Record<string, string>;
|
|
24
|
+
apiConfig: ApiClientConfig;
|
|
25
|
+
onLog?: (message: string) => void;
|
|
26
|
+
renderPrompt?: (prompt: string) => string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
30
|
+
const OUTPUT_FLUSH_INTERVAL_MS = 2_000;
|
|
31
|
+
const QA_POLL_INTERVAL_MS = 2_000;
|
|
32
|
+
const WORKER_STATE_FILE = 'worker.json';
|
|
33
|
+
|
|
34
|
+
export async function startWorkerDaemon(options: WorkerDaemonOptions): Promise<void> {
|
|
35
|
+
const { root, agent, agents, apiConfig, onLog, renderPrompt } = options;
|
|
36
|
+
const workerName = options.name ?? hostname();
|
|
37
|
+
const sddDir = join(root, '.sdd');
|
|
38
|
+
const stateFile = join(sddDir, WORKER_STATE_FILE);
|
|
39
|
+
|
|
40
|
+
const log = onLog ?? ((msg: string) => process.stderr.write(`[worker] ${msg}\n`));
|
|
41
|
+
const shortId = (id: string) => id.slice(0, 8);
|
|
42
|
+
|
|
43
|
+
// --- Read current branch (informational — no checkout) ---
|
|
44
|
+
const branch = getCurrentBranch(root) ?? undefined;
|
|
45
|
+
|
|
46
|
+
// --- Register ---
|
|
47
|
+
log(`Registering worker "${workerName}" with agent "${agent}"${branch ? ` (branch: ${branch})` : ''}...`);
|
|
48
|
+
const registration = await registerWorker(apiConfig, workerName, agent, branch, {
|
|
49
|
+
hostname: hostname(),
|
|
50
|
+
platform: process.platform,
|
|
51
|
+
arch: process.arch,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const state: WorkerState = {
|
|
55
|
+
workerId: registration.id,
|
|
56
|
+
name: workerName,
|
|
57
|
+
registeredAt: registration.registered_at,
|
|
58
|
+
};
|
|
59
|
+
await writeFile(stateFile, JSON.stringify(state, null, 2), 'utf-8');
|
|
60
|
+
log(`Registered as worker ${shortId(registration.id)}`);
|
|
61
|
+
|
|
62
|
+
let running = true;
|
|
63
|
+
let currentJobKill: (() => void) | null = null;
|
|
64
|
+
|
|
65
|
+
// --- Graceful shutdown ---
|
|
66
|
+
const shutdown = async () => {
|
|
67
|
+
log('Shutting down...');
|
|
68
|
+
running = false;
|
|
69
|
+
if (currentJobKill) {
|
|
70
|
+
currentJobKill();
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
await workerHeartbeat(apiConfig, registration.id, 'online');
|
|
74
|
+
} catch {
|
|
75
|
+
// best effort
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
process.on('SIGINT', () => { shutdown(); });
|
|
80
|
+
process.on('SIGTERM', () => { shutdown(); });
|
|
81
|
+
|
|
82
|
+
// --- Heartbeat loop ---
|
|
83
|
+
const heartbeatLoop = async () => {
|
|
84
|
+
while (running) {
|
|
85
|
+
try {
|
|
86
|
+
await workerHeartbeat(apiConfig, registration.id, currentJobKill ? 'busy' : 'online');
|
|
87
|
+
} catch (err) {
|
|
88
|
+
const msg = (err as Error).message;
|
|
89
|
+
if (!msg.includes('abort')) {
|
|
90
|
+
log(`Heartbeat error: ${msg}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
await sleep(HEARTBEAT_INTERVAL_MS);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// --- Poll & execute loop ---
|
|
98
|
+
const pollLoop = async () => {
|
|
99
|
+
while (running) {
|
|
100
|
+
try {
|
|
101
|
+
const job = await workerPoll(apiConfig, registration.id);
|
|
102
|
+
if (job && running) {
|
|
103
|
+
await executeJob(job);
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
if (!running) break;
|
|
107
|
+
const msg = (err as Error).message;
|
|
108
|
+
// Don't log abort errors during shutdown
|
|
109
|
+
if (!msg.includes('abort')) {
|
|
110
|
+
log(`Poll error: ${msg}`);
|
|
111
|
+
}
|
|
112
|
+
await sleep(3_000);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// --- Job execution ---
|
|
118
|
+
const executeJob = async (job: WorkerJobAssignment) => {
|
|
119
|
+
const jobDesc = job.entity_type ? `${job.entity_type}/${job.entity_id}` : 'sync';
|
|
120
|
+
const jid = shortId(job.job_id);
|
|
121
|
+
log(`┌─ Job ${jid} (${jobDesc})`);
|
|
122
|
+
log(`│ Agent: ${job.agent}${job.model ? ` Model: ${job.model}` : ''}${job.branch ? ` Branch: ${job.branch}` : ''}`);
|
|
123
|
+
const promptPreview = job.prompt.split('\n').find(l => l.trim().length > 0) ?? '';
|
|
124
|
+
log(`└─ ${promptPreview.length > 80 ? promptPreview.slice(0, 80) + '…' : promptPreview}`);
|
|
125
|
+
|
|
126
|
+
// Checkout branch if the job explicitly requests one different from the current
|
|
127
|
+
if (job.branch && job.branch !== getCurrentBranch(root)) {
|
|
128
|
+
log(`Checking out branch "${job.branch}" for job ${job.job_id}...`);
|
|
129
|
+
try {
|
|
130
|
+
checkoutBranch(root, job.branch);
|
|
131
|
+
} catch (err) {
|
|
132
|
+
log(`Warning: could not checkout branch "${job.branch}": ${(err as Error).message}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Capture git state before the job starts so we can diff at the end
|
|
137
|
+
const baseCommit = getCurrentCommit(root);
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
await workerJobStarted(apiConfig, job.job_id);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
log(`Failed to notify job start: ${(err as Error).message}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Buffer for batched output
|
|
146
|
+
let outputBuffer: string[] = [];
|
|
147
|
+
let lastFlush = Date.now();
|
|
148
|
+
let lastAnswerSequence = 0;
|
|
149
|
+
let waitingForAnswer = false;
|
|
150
|
+
|
|
151
|
+
const flushOutput = async () => {
|
|
152
|
+
if (outputBuffer.length === 0) return;
|
|
153
|
+
const lines = outputBuffer.splice(0);
|
|
154
|
+
try {
|
|
155
|
+
await workerJobOutput(apiConfig, job.job_id, lines);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
log(`Output flush error: ${(err as Error).message}`);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Start the agent with interactive mode
|
|
162
|
+
const handle = await startAgent({
|
|
163
|
+
root,
|
|
164
|
+
prompt: job.prompt,
|
|
165
|
+
agent: job.agent,
|
|
166
|
+
model: job.model,
|
|
167
|
+
agents,
|
|
168
|
+
onOutput: (data: string) => {
|
|
169
|
+
// Split into lines but preserve partial lines
|
|
170
|
+
const lines = data.split('\n');
|
|
171
|
+
for (const line of lines) {
|
|
172
|
+
// Suppress the Claude CLI stdin warning — prompt is passed via -p flag,
|
|
173
|
+
// stdin is kept open only for Q&A relay and does not need initial data.
|
|
174
|
+
if (line.includes('no stdin data received') || line.includes('redirect stdin explicitly')) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (line.length > 0) {
|
|
178
|
+
outputBuffer.push(line);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Flush if enough time has passed
|
|
183
|
+
if (Date.now() - lastFlush >= OUTPUT_FLUSH_INTERVAL_MS) {
|
|
184
|
+
lastFlush = Date.now();
|
|
185
|
+
flushOutput();
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
currentJobKill = handle.kill;
|
|
191
|
+
|
|
192
|
+
// Periodic output flush + answer polling loop
|
|
193
|
+
const flushAndPollLoop = async () => {
|
|
194
|
+
while (running) {
|
|
195
|
+
// Flush pending output
|
|
196
|
+
await flushOutput();
|
|
197
|
+
lastFlush = Date.now();
|
|
198
|
+
|
|
199
|
+
// If we detected a question, poll for answers
|
|
200
|
+
if (waitingForAnswer) {
|
|
201
|
+
try {
|
|
202
|
+
const answers = await workerJobAnswers(apiConfig, job.job_id, lastAnswerSequence);
|
|
203
|
+
if (answers.length > 0) {
|
|
204
|
+
for (const ans of answers) {
|
|
205
|
+
handle.writeStdin(ans.content + '\n');
|
|
206
|
+
lastAnswerSequence = ans.sequence;
|
|
207
|
+
}
|
|
208
|
+
waitingForAnswer = false;
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
log(`Answer poll error: ${(err as Error).message}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
await sleep(QA_POLL_INTERVAL_MS);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
// Run the flush/poll loop concurrently with the agent
|
|
220
|
+
const flushPromise = flushAndPollLoop();
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const exitCode = await handle.exitPromise;
|
|
224
|
+
|
|
225
|
+
// Final flush
|
|
226
|
+
running = true; // temporarily re-enable to allow final flush
|
|
227
|
+
await flushOutput();
|
|
228
|
+
|
|
229
|
+
const changedFiles = getJobChangedFiles(root, baseCommit);
|
|
230
|
+
const filesMsg = changedFiles.length > 0 ? `, ${changedFiles.length} file(s) changed` : '';
|
|
231
|
+
log(`Job ${jid} done — exit ${exitCode}${filesMsg}`);
|
|
232
|
+
await workerJobCompleted(apiConfig, job.job_id, exitCode, changedFiles);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
log(`Job ${jid} error: ${(err as Error).message}`);
|
|
235
|
+
try {
|
|
236
|
+
const changedFiles = getJobChangedFiles(root, baseCommit);
|
|
237
|
+
await workerJobCompleted(apiConfig, job.job_id, 1, changedFiles);
|
|
238
|
+
} catch {
|
|
239
|
+
// best effort
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
currentJobKill = null;
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// --- Start concurrent loops ---
|
|
247
|
+
log(`Worker "${workerName}" is online. Waiting for jobs… (id: ${shortId(registration.id)})`);
|
|
248
|
+
await Promise.all([heartbeatLoop(), pollLoop()]);
|
|
249
|
+
log('Worker stopped.');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function sleep(ms: number): Promise<void> {
|
|
253
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
254
|
+
}
|