@clicksmith/daemon 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +81 -0
- package/dist/chunk-FY7JGOX6.js +540 -0
- package/dist/chunk-FY7JGOX6.js.map +1 -0
- package/dist/chunk-UVRW6O46.js +542 -0
- package/dist/chunk-UVRW6O46.js.map +1 -0
- package/dist/cli.js +95 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +416 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.js +10 -0
- package/dist/mcp.js.map +1 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ClickSmith contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# @clicksmith/daemon
|
|
2
|
+
|
|
3
|
+
The localhost brain of ClickSmith. A single Node process that owns sessions,
|
|
4
|
+
runs, and **safety**. It exposes:
|
|
5
|
+
|
|
6
|
+
- **HTTP** (Fastify) — capture, submit, apply, session reads.
|
|
7
|
+
- **WebSocket** (Fastify) — live run events streamed to the extension.
|
|
8
|
+
- **MCP** (stdio) — read-only tools for any MCP-capable coding agent.
|
|
9
|
+
- **Git sandbox orchestration** — throwaway worktrees, dirty-tree refusal, apply.
|
|
10
|
+
- **Config-driven agent launching** — spawns the selected agent via `execa`.
|
|
11
|
+
|
|
12
|
+
Everything stays local. No cloud, no telemetry.
|
|
13
|
+
|
|
14
|
+
## Run it
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
clicksmith daemon --port 8722 # HTTP + WS + state under .clicksmith/
|
|
18
|
+
clicksmith mcp # read-only MCP stdio server
|
|
19
|
+
clicksmith version
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## HTTP surface
|
|
23
|
+
|
|
24
|
+
| Method & path | Purpose |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `GET /health` | Liveness + daemon metadata. |
|
|
27
|
+
| `POST /capture` | Create/append the active session for an app/route. |
|
|
28
|
+
| `POST /submit` | Finalize a bundle and start a run. `409` if the tree is dirty. |
|
|
29
|
+
| `POST /apply/:runId` | Merge the sandbox back; reports conflicts. |
|
|
30
|
+
| `GET /session/:id` | Read a session. |
|
|
31
|
+
| `DELETE /element/:sessionId/:elementId` | Remove a captured mark. |
|
|
32
|
+
|
|
33
|
+
WebSocket (`/ws`) streams: `capture-ack`, `element-removed`, `agent-started`,
|
|
34
|
+
`agent-log`, `plan-ready`, `agent-done`, `agent-error`, `apply-started`,
|
|
35
|
+
`apply-done`, `apply-error`. Send `{ "type": "subscribe" }` to replay buffered
|
|
36
|
+
events on reconnect.
|
|
37
|
+
|
|
38
|
+
## Safety model
|
|
39
|
+
|
|
40
|
+
`POST /submit` with the default `plan + worktree` options:
|
|
41
|
+
|
|
42
|
+
1. Detects the repo root and base commit.
|
|
43
|
+
2. **Refuses** (`409`) if the working tree has uncommitted changes (ignoring
|
|
44
|
+
`.clicksmith/` itself) — unless you explicitly choose `inplace`.
|
|
45
|
+
3. Creates a throwaway worktree at `.clicksmith/worktrees/<runId>` on branch
|
|
46
|
+
`clicksmith/<runId>`. Falls back to a dedicated branch if worktrees are
|
|
47
|
+
unavailable.
|
|
48
|
+
4. Launches the agent **in the sandbox** — your main tree is never touched.
|
|
49
|
+
5. Captures the agent's stdout as `plan.md` and a binary-safe `diff.patch`.
|
|
50
|
+
6. `POST /apply/:runId` 3-way-applies the patch onto the main tree, commits,
|
|
51
|
+
records revert metadata, and cleans up the worktree. Conflicts are reported,
|
|
52
|
+
not forced.
|
|
53
|
+
|
|
54
|
+
## Persistence layout
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
.clicksmith/ (or OS cache dir when not in a repo)
|
|
58
|
+
sessions/<id>.json
|
|
59
|
+
runs/<runId>/
|
|
60
|
+
bundle.json plan.md diff.patch agent.log run.json
|
|
61
|
+
worktrees/<runId>/ (throwaway git worktree)
|
|
62
|
+
agents.config.json (optional project agent overrides)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Unsubmitted sessions expire after a configurable default of 24 hours.
|
|
66
|
+
|
|
67
|
+
## Programmatic use
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
import { DaemonService, buildServer, resolveDaemonConfig } from '@clicksmith/daemon';
|
|
71
|
+
|
|
72
|
+
const config = await resolveDaemonConfig({ port: 8722 });
|
|
73
|
+
const service = new DaemonService({ config });
|
|
74
|
+
await service.init();
|
|
75
|
+
const app = await buildServer(service);
|
|
76
|
+
await app.listen({ host: config.host, port: config.port });
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The `DaemonService` is framework-agnostic and fully unit-/integration-tested,
|
|
80
|
+
including the end-to-end acceptance flow (capture → plan in worktree → prove the
|
|
81
|
+
main tree is untouched → apply → cleanup).
|
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
import {
|
|
2
|
+
FileStore,
|
|
3
|
+
Git,
|
|
4
|
+
describeSandbox,
|
|
5
|
+
version
|
|
6
|
+
} from "./chunk-UVRW6O46.js";
|
|
7
|
+
|
|
8
|
+
// src/events.ts
|
|
9
|
+
var EventBus = class {
|
|
10
|
+
listeners = /* @__PURE__ */ new Set();
|
|
11
|
+
history = [];
|
|
12
|
+
maxHistory;
|
|
13
|
+
constructor(maxHistory = 500) {
|
|
14
|
+
this.maxHistory = maxHistory;
|
|
15
|
+
}
|
|
16
|
+
subscribe(listener) {
|
|
17
|
+
this.listeners.add(listener);
|
|
18
|
+
return () => this.listeners.delete(listener);
|
|
19
|
+
}
|
|
20
|
+
emit(event) {
|
|
21
|
+
this.history.push(event);
|
|
22
|
+
if (this.history.length > this.maxHistory) this.history.shift();
|
|
23
|
+
for (const listener of this.listeners) {
|
|
24
|
+
try {
|
|
25
|
+
listener(event);
|
|
26
|
+
} catch {
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Replay buffered events, optionally filtered to a run id. */
|
|
31
|
+
replay(runId) {
|
|
32
|
+
if (!runId) return [...this.history];
|
|
33
|
+
return this.history.filter((e) => "runId" in e && e.runId === runId);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// src/launcher.ts
|
|
38
|
+
import { execa } from "execa";
|
|
39
|
+
async function launchAgent(spec, handlers) {
|
|
40
|
+
const subprocess = execa(spec.command, spec.args, {
|
|
41
|
+
cwd: spec.cwd,
|
|
42
|
+
env: { ...process.env, ...spec.env },
|
|
43
|
+
reject: false,
|
|
44
|
+
all: false,
|
|
45
|
+
cancelSignal: handlers.signal
|
|
46
|
+
});
|
|
47
|
+
let stdout = "";
|
|
48
|
+
subprocess.stdout?.on("data", (data) => {
|
|
49
|
+
const chunk = data.toString();
|
|
50
|
+
stdout += chunk;
|
|
51
|
+
handlers.onLog("stdout", chunk);
|
|
52
|
+
});
|
|
53
|
+
subprocess.stderr?.on("data", (data) => {
|
|
54
|
+
handlers.onLog("stderr", data.toString());
|
|
55
|
+
});
|
|
56
|
+
const result = await subprocess;
|
|
57
|
+
return {
|
|
58
|
+
exitCode: result.exitCode ?? (result.isCanceled ? 130 : 0),
|
|
59
|
+
stdout,
|
|
60
|
+
canceled: Boolean(result.isCanceled)
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// src/enrichment.ts
|
|
65
|
+
async function enrichBundle(bundle, provider) {
|
|
66
|
+
if (!provider) return bundle;
|
|
67
|
+
try {
|
|
68
|
+
const enrichment = await provider.enrich(bundle);
|
|
69
|
+
if (!enrichment) return bundle;
|
|
70
|
+
return { ...bundle, enrichment };
|
|
71
|
+
} catch (err) {
|
|
72
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
73
|
+
return {
|
|
74
|
+
...bundle,
|
|
75
|
+
enrichment: {
|
|
76
|
+
source: "code-review-graph",
|
|
77
|
+
perElement: bundle.enrichment?.perElement ?? [],
|
|
78
|
+
warnings: [...bundle.enrichment?.warnings ?? [], `enrichment failed: ${message}`]
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/run-manager.ts
|
|
85
|
+
import { mkdir } from "fs/promises";
|
|
86
|
+
import { join } from "path";
|
|
87
|
+
import {
|
|
88
|
+
configToAdapter,
|
|
89
|
+
defaultBinExists,
|
|
90
|
+
renderInstructionBody,
|
|
91
|
+
resolveAgent
|
|
92
|
+
} from "@clicksmith/agent-config";
|
|
93
|
+
import {
|
|
94
|
+
newRunId
|
|
95
|
+
} from "@clicksmith/core";
|
|
96
|
+
var RefusalError = class extends Error {
|
|
97
|
+
code = "DIRTY_TREE";
|
|
98
|
+
};
|
|
99
|
+
var COMMIT_PREFIX = "ClickSmith";
|
|
100
|
+
var RunManager = class {
|
|
101
|
+
constructor(deps) {
|
|
102
|
+
this.deps = deps;
|
|
103
|
+
}
|
|
104
|
+
deps;
|
|
105
|
+
/**
|
|
106
|
+
* Prepare a sandbox and start an agent run. The sandbox is prepared
|
|
107
|
+
* synchronously so a dirty-tree refusal surfaces as an error to the caller;
|
|
108
|
+
* the agent itself runs in the background, emitting WebSocket events.
|
|
109
|
+
*/
|
|
110
|
+
async createRun(input) {
|
|
111
|
+
const { store, config, bus, logger } = this.deps;
|
|
112
|
+
const agentConfig = resolveAgent(config.agents, input.execution.agentId);
|
|
113
|
+
if (!agentConfig) {
|
|
114
|
+
throw new RefusalError(`No agent configured (requested: ${input.execution.agentId ?? "default"}).`);
|
|
115
|
+
}
|
|
116
|
+
const runId = newRunId();
|
|
117
|
+
const now = /* @__PURE__ */ new Date();
|
|
118
|
+
const repoRoot = config.repoRoot;
|
|
119
|
+
const isolation = repoRoot ? input.execution.isolation : "inplace";
|
|
120
|
+
let sandbox = null;
|
|
121
|
+
let baseCommit = null;
|
|
122
|
+
let baseBranch = null;
|
|
123
|
+
if (repoRoot) {
|
|
124
|
+
const git = new Git(repoRoot);
|
|
125
|
+
baseCommit = await git.headCommit();
|
|
126
|
+
baseBranch = await safe(() => git.currentBranch());
|
|
127
|
+
const baseRef = input.execution.baseRef ?? baseCommit;
|
|
128
|
+
if (isolation !== "inplace" && await git.isDirty({ exclude: [".clicksmith/", ".clicksmith"] })) {
|
|
129
|
+
throw new RefusalError(
|
|
130
|
+
`Refusing to run in ${isolation} isolation: the working tree has uncommitted changes. Commit or stash them, or use inplace isolation explicitly.`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
sandbox = await this.prepareSandbox(git, runId, isolation, baseRef, repoRoot, baseCommit, logger);
|
|
134
|
+
}
|
|
135
|
+
const enriched = await enrichBundle(input, this.deps.enrichment);
|
|
136
|
+
await store.saveBundle(runId, enriched);
|
|
137
|
+
const run = {
|
|
138
|
+
runId,
|
|
139
|
+
sessionId: enriched.sessionId,
|
|
140
|
+
agentId: agentConfig.id,
|
|
141
|
+
status: "running",
|
|
142
|
+
createdAt: now.toISOString(),
|
|
143
|
+
updatedAt: now.toISOString(),
|
|
144
|
+
mode: enriched.execution.mode,
|
|
145
|
+
isolation,
|
|
146
|
+
prompt: enriched.prompt,
|
|
147
|
+
repoRoot,
|
|
148
|
+
baseCommit,
|
|
149
|
+
baseBranch,
|
|
150
|
+
sandbox,
|
|
151
|
+
revert: null
|
|
152
|
+
};
|
|
153
|
+
await store.saveRun(run);
|
|
154
|
+
bus.emit({ type: "agent-started", runId, sessionId: run.sessionId, agentId: run.agentId, sandbox });
|
|
155
|
+
void this.execute(run, enriched, agentConfig).catch((err) => {
|
|
156
|
+
logger.error(`run ${runId} crashed`, err);
|
|
157
|
+
});
|
|
158
|
+
return { run };
|
|
159
|
+
}
|
|
160
|
+
async prepareSandbox(git, runId, isolation, baseRef, repoRoot, baseCommit, logger) {
|
|
161
|
+
const branch = `clicksmith/${runId}`;
|
|
162
|
+
if (isolation === "inplace") {
|
|
163
|
+
return describeSandbox("inplace", repoRoot, null, baseCommit);
|
|
164
|
+
}
|
|
165
|
+
if (isolation === "worktree") {
|
|
166
|
+
if (await git.supportsWorktree()) {
|
|
167
|
+
const path = this.deps.store.paths.sandboxDir(runId);
|
|
168
|
+
await mkdir(join(path, ".."), { recursive: true });
|
|
169
|
+
await git.createWorktree(path, branch, baseRef);
|
|
170
|
+
return describeSandbox("worktree", path, branch, baseCommit);
|
|
171
|
+
}
|
|
172
|
+
logger.warn("git worktrees unavailable; falling back to a dedicated branch");
|
|
173
|
+
}
|
|
174
|
+
await git.createBranch(branch, baseRef);
|
|
175
|
+
return describeSandbox("branch", repoRoot, branch, baseCommit);
|
|
176
|
+
}
|
|
177
|
+
async execute(run, bundle, agentConfig) {
|
|
178
|
+
const { store, config, bus, logger } = this.deps;
|
|
179
|
+
const sandboxPath = run.sandbox?.path ?? config.cwd;
|
|
180
|
+
const instructionFile = await this.resolveInstructionFile(run, agentConfig);
|
|
181
|
+
const ctx = {
|
|
182
|
+
bundlePath: store.bundlePath(run.runId),
|
|
183
|
+
prompt: bundle.prompt,
|
|
184
|
+
instructionFile,
|
|
185
|
+
mode: bundle.execution.mode,
|
|
186
|
+
mcpServer: "clicksmith",
|
|
187
|
+
cwd: sandboxPath,
|
|
188
|
+
isolation: run.isolation,
|
|
189
|
+
agentId: agentConfig.id,
|
|
190
|
+
binExists: this.deps.binExists ?? defaultBinExists
|
|
191
|
+
};
|
|
192
|
+
const adapter = configToAdapter(agentConfig);
|
|
193
|
+
if (!await adapter.isAvailable(ctx)) {
|
|
194
|
+
await this.fail(run, `Agent "${agentConfig.id}" is not available on PATH.`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
const spec = adapter.buildCommand(ctx);
|
|
198
|
+
logger.info(`run ${run.runId}: ${spec.command} ${spec.args.join(" ")}`);
|
|
199
|
+
let result;
|
|
200
|
+
try {
|
|
201
|
+
result = await launchAgent(spec, {
|
|
202
|
+
onLog: (stream, chunk) => {
|
|
203
|
+
void store.appendLog(run.runId, chunk);
|
|
204
|
+
bus.emit({ type: "agent-log", runId: run.runId, stream, chunk });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
} catch (err) {
|
|
208
|
+
await this.fail(run, err instanceof Error ? err.message : String(err));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const plan = result.stdout.trim();
|
|
212
|
+
let diff = "";
|
|
213
|
+
if (run.sandbox && run.repoRoot) {
|
|
214
|
+
diff = await Git.captureDiff(run.sandbox.path);
|
|
215
|
+
}
|
|
216
|
+
if (plan) await store.writeArtifact(run.runId, "plan.md", plan);
|
|
217
|
+
if (diff) await store.writeArtifact(run.runId, "diff.patch", diff);
|
|
218
|
+
run.exitCode = result.exitCode;
|
|
219
|
+
run.hasPlan = plan.length > 0;
|
|
220
|
+
run.hasDiff = diff.length > 0;
|
|
221
|
+
if (result.exitCode !== 0) {
|
|
222
|
+
await this.fail(run, `Agent exited with code ${result.exitCode}.`);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
run.status = "plan-ready";
|
|
226
|
+
run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
227
|
+
await store.saveRun(run);
|
|
228
|
+
bus.emit({
|
|
229
|
+
type: "plan-ready",
|
|
230
|
+
runId: run.runId,
|
|
231
|
+
...plan ? { plan } : {},
|
|
232
|
+
...diff ? { diff } : {}
|
|
233
|
+
});
|
|
234
|
+
bus.emit({ type: "agent-done", runId: run.runId, exitCode: result.exitCode });
|
|
235
|
+
if (bundle.execution.autoApply) {
|
|
236
|
+
logger.info(`run ${run.runId}: autoApply enabled, applying`);
|
|
237
|
+
await this.apply(run.runId);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async fail(run, message) {
|
|
241
|
+
run.status = "error";
|
|
242
|
+
run.error = message;
|
|
243
|
+
run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
244
|
+
await this.deps.store.saveRun(run);
|
|
245
|
+
this.deps.bus.emit({ type: "agent-error", runId: run.runId, message });
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Merge a finished run's sandbox changes back into the working tree. Reports
|
|
249
|
+
* conflicts, commits on success, records revert metadata, and cleans up the
|
|
250
|
+
* sandbox.
|
|
251
|
+
*/
|
|
252
|
+
async apply(runId) {
|
|
253
|
+
const { store, bus, logger } = this.deps;
|
|
254
|
+
const run = await store.getRun(runId);
|
|
255
|
+
if (!run) throw new Error(`Unknown run: ${runId}`);
|
|
256
|
+
if (!run.repoRoot || !run.sandbox) {
|
|
257
|
+
throw new Error(`Run ${runId} has no git sandbox to apply.`);
|
|
258
|
+
}
|
|
259
|
+
bus.emit({ type: "apply-started", runId });
|
|
260
|
+
const git = new Git(run.repoRoot);
|
|
261
|
+
const previousHead = await git.headCommit();
|
|
262
|
+
const message = `${COMMIT_PREFIX} run ${runId}: ${truncate(run.prompt, 72)}`;
|
|
263
|
+
try {
|
|
264
|
+
let commit;
|
|
265
|
+
if (run.sandbox.isolation === "worktree") {
|
|
266
|
+
const diff = await store.readArtifact(runId, "diff.patch") ?? "";
|
|
267
|
+
const applied = await git.applyPatch(diff);
|
|
268
|
+
if (!applied.ok) return await this.applyConflict(run, applied.conflicts);
|
|
269
|
+
commit = diff.trim() ? await git.commit(message) : previousHead;
|
|
270
|
+
await this.cleanupSandbox(run);
|
|
271
|
+
} else if (run.sandbox.isolation === "branch") {
|
|
272
|
+
if (await git.hasChanges()) await git.commit(message);
|
|
273
|
+
if (run.baseBranch) await git.switchTo(run.baseBranch);
|
|
274
|
+
const merged = await git.merge(run.sandbox.branch, message);
|
|
275
|
+
if (!merged.ok) return await this.applyConflict(run, merged.conflicts);
|
|
276
|
+
commit = await git.headCommit();
|
|
277
|
+
await git.deleteBranch(run.sandbox.branch);
|
|
278
|
+
} else {
|
|
279
|
+
commit = await git.hasChanges() ? await git.commit(message) : previousHead;
|
|
280
|
+
}
|
|
281
|
+
run.status = "applied";
|
|
282
|
+
run.applied = { ...commit ? { commit } : {}, at: (/* @__PURE__ */ new Date()).toISOString() };
|
|
283
|
+
run.revert = {
|
|
284
|
+
previousHead,
|
|
285
|
+
...commit && commit !== previousHead ? { appliedCommit: commit } : {},
|
|
286
|
+
instructions: commit && commit !== previousHead ? `git revert ${commit} # or: git reset --hard ${previousHead}` : "No commit was created; nothing to revert."
|
|
287
|
+
};
|
|
288
|
+
run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
289
|
+
await store.saveRun(run);
|
|
290
|
+
bus.emit({ type: "apply-done", runId, ...commit ? { commit } : {} });
|
|
291
|
+
return { applied: true, ...commit ? { commit } : {} };
|
|
292
|
+
} catch (err) {
|
|
293
|
+
const messageText = err instanceof Error ? err.message : String(err);
|
|
294
|
+
logger.error(`apply ${runId} failed`, messageText);
|
|
295
|
+
run.status = "apply-error";
|
|
296
|
+
run.error = messageText;
|
|
297
|
+
await store.saveRun(run);
|
|
298
|
+
bus.emit({ type: "apply-error", runId, message: messageText });
|
|
299
|
+
return { applied: false };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
async applyConflict(run, conflicts) {
|
|
303
|
+
run.status = "apply-error";
|
|
304
|
+
run.error = `Apply conflicts in: ${conflicts.join(", ") || "unknown files"}`;
|
|
305
|
+
run.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
306
|
+
await this.deps.store.saveRun(run);
|
|
307
|
+
this.deps.bus.emit({
|
|
308
|
+
type: "apply-error",
|
|
309
|
+
runId: run.runId,
|
|
310
|
+
message: run.error,
|
|
311
|
+
conflicts
|
|
312
|
+
});
|
|
313
|
+
return { applied: false, conflicts };
|
|
314
|
+
}
|
|
315
|
+
async cleanupSandbox(run) {
|
|
316
|
+
if (!run.repoRoot || !run.sandbox) return;
|
|
317
|
+
if (run.sandbox.isolation === "worktree") {
|
|
318
|
+
const git = new Git(run.repoRoot);
|
|
319
|
+
await git.removeWorktree(run.sandbox.path, run.sandbox.branch ?? void 0);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Resolve the instruction file passed to the agent. Prefer the project's
|
|
324
|
+
* rendered file if it exists; otherwise write a run-local one from the shared
|
|
325
|
+
* template so every agent always has instructions.
|
|
326
|
+
*/
|
|
327
|
+
async resolveInstructionFile(run, agentConfig) {
|
|
328
|
+
const { config, store } = this.deps;
|
|
329
|
+
if (config.repoRoot && agentConfig.instructions) {
|
|
330
|
+
const projectFile = join(config.repoRoot, agentConfig.instructions.file);
|
|
331
|
+
if (await fileExists(projectFile)) return projectFile;
|
|
332
|
+
}
|
|
333
|
+
const body = renderInstructionBody({ daemonPort: config.port });
|
|
334
|
+
return store.writeArtifact(run.runId, "AGENT_INSTRUCTIONS.md", body);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
async function fileExists(path) {
|
|
338
|
+
const { access } = await import("fs/promises");
|
|
339
|
+
try {
|
|
340
|
+
await access(path);
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function safe(fn) {
|
|
347
|
+
try {
|
|
348
|
+
return await fn();
|
|
349
|
+
} catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
function truncate(s, n) {
|
|
354
|
+
return s.length <= n ? s : `${s.slice(0, n - 1)}\u2026`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/daemon-service.ts
|
|
358
|
+
import {
|
|
359
|
+
appendElement,
|
|
360
|
+
createSession,
|
|
361
|
+
ExecutionOptionsSchema,
|
|
362
|
+
finalizeSession,
|
|
363
|
+
removeElement,
|
|
364
|
+
touchSession
|
|
365
|
+
} from "@clicksmith/core";
|
|
366
|
+
var DaemonService = class {
|
|
367
|
+
config;
|
|
368
|
+
store;
|
|
369
|
+
bus;
|
|
370
|
+
runs;
|
|
371
|
+
constructor(opts) {
|
|
372
|
+
this.config = opts.config;
|
|
373
|
+
this.store = new FileStore(opts.config.storageRoot);
|
|
374
|
+
this.bus = new EventBus();
|
|
375
|
+
this.runs = new RunManager({
|
|
376
|
+
store: this.store,
|
|
377
|
+
config: opts.config,
|
|
378
|
+
bus: this.bus,
|
|
379
|
+
logger: opts.config.logger,
|
|
380
|
+
...opts.enrichment ? { enrichment: opts.enrichment } : {},
|
|
381
|
+
...opts.binExists ? { binExists: opts.binExists } : {}
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async init() {
|
|
385
|
+
await this.store.init();
|
|
386
|
+
await this.store.cleanupExpired();
|
|
387
|
+
}
|
|
388
|
+
/* ------------------------------- capture ------------------------------ */
|
|
389
|
+
/** Create or append to the active session for an app/route. */
|
|
390
|
+
async capture(req) {
|
|
391
|
+
const now = /* @__PURE__ */ new Date();
|
|
392
|
+
let session = req.sessionId ? await this.store.getSession(req.sessionId) : void 0;
|
|
393
|
+
if (!session) {
|
|
394
|
+
session = createSession({ app: req.app, now, ttlMs: this.config.ttlMs });
|
|
395
|
+
} else {
|
|
396
|
+
session = touchSession(session, now, this.config.ttlMs);
|
|
397
|
+
}
|
|
398
|
+
const { session: next, element } = appendElement(session, req.element, now);
|
|
399
|
+
await this.store.saveSession(next);
|
|
400
|
+
this.bus.emit({ type: "capture-ack", sessionId: next.id, element });
|
|
401
|
+
return { sessionId: next.id, element };
|
|
402
|
+
}
|
|
403
|
+
async removeElement(sessionId, elementId) {
|
|
404
|
+
const session = await this.store.getSession(sessionId);
|
|
405
|
+
if (!session) return { removed: false };
|
|
406
|
+
const { session: next, removed } = removeElement(session, elementId);
|
|
407
|
+
if (removed) {
|
|
408
|
+
await this.store.saveSession(next);
|
|
409
|
+
this.bus.emit({ type: "element-removed", sessionId, elementId });
|
|
410
|
+
}
|
|
411
|
+
return { removed };
|
|
412
|
+
}
|
|
413
|
+
async getSession(id) {
|
|
414
|
+
return this.store.getSession(id);
|
|
415
|
+
}
|
|
416
|
+
/* ------------------------------- submit ------------------------------- */
|
|
417
|
+
/** Finalize a session into a bundle and start a run. */
|
|
418
|
+
async submit(req) {
|
|
419
|
+
const session = await this.store.getSession(req.sessionId);
|
|
420
|
+
if (!session) throw new NotFoundError(`Unknown session: ${req.sessionId}`);
|
|
421
|
+
const execution = ExecutionOptionsSchema.parse(req.execution ?? {});
|
|
422
|
+
const bundle = finalizeSession(session, {
|
|
423
|
+
prompt: req.prompt,
|
|
424
|
+
execution,
|
|
425
|
+
...req.enrichment ? { enrichment: req.enrichment } : {}
|
|
426
|
+
});
|
|
427
|
+
const submitted = { ...session, status: "submitted", prompt: req.prompt };
|
|
428
|
+
await this.store.saveSession(submitted);
|
|
429
|
+
const { run } = await this.runs.createRun(bundle);
|
|
430
|
+
return { runId: run.runId, bundle };
|
|
431
|
+
}
|
|
432
|
+
async apply(runId) {
|
|
433
|
+
return this.runs.apply(runId);
|
|
434
|
+
}
|
|
435
|
+
/* ------------------------------- health ------------------------------- */
|
|
436
|
+
async health() {
|
|
437
|
+
const sessions = await this.store.listSessions();
|
|
438
|
+
return {
|
|
439
|
+
ok: true,
|
|
440
|
+
name: "clicksmith-daemon",
|
|
441
|
+
version,
|
|
442
|
+
host: this.config.host,
|
|
443
|
+
port: this.config.port,
|
|
444
|
+
repoRoot: this.config.repoRoot,
|
|
445
|
+
activeSessions: sessions.filter((s) => s.status === "active").length
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
var NotFoundError = class extends Error {
|
|
450
|
+
code = "NOT_FOUND";
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
// src/server.ts
|
|
454
|
+
import Fastify from "fastify";
|
|
455
|
+
import websocket from "@fastify/websocket";
|
|
456
|
+
import {
|
|
457
|
+
CaptureRequestSchema,
|
|
458
|
+
SubmitRequestSchema
|
|
459
|
+
} from "@clicksmith/core";
|
|
460
|
+
async function buildServer(service) {
|
|
461
|
+
const app = Fastify({ logger: false });
|
|
462
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
463
|
+
reply.header("Access-Control-Allow-Origin", "*");
|
|
464
|
+
reply.header("Access-Control-Allow-Methods", "GET,POST,DELETE,OPTIONS");
|
|
465
|
+
reply.header("Access-Control-Allow-Headers", "content-type");
|
|
466
|
+
if (req.method === "OPTIONS") {
|
|
467
|
+
reply.code(204).send();
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
await app.register(websocket);
|
|
471
|
+
app.get("/health", async () => service.health());
|
|
472
|
+
app.post("/capture", async (req, reply) => {
|
|
473
|
+
const parsed = CaptureRequestSchema.safeParse(req.body);
|
|
474
|
+
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
|
475
|
+
return service.capture(parsed.data);
|
|
476
|
+
});
|
|
477
|
+
app.post("/submit", async (req, reply) => {
|
|
478
|
+
const parsed = SubmitRequestSchema.safeParse(req.body);
|
|
479
|
+
if (!parsed.success) return reply.code(400).send({ error: parsed.error.message });
|
|
480
|
+
try {
|
|
481
|
+
return await service.submit(parsed.data);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
if (err instanceof NotFoundError) return reply.code(404).send({ error: err.message });
|
|
484
|
+
if (err instanceof RefusalError) return reply.code(409).send({ error: err.message, code: err.code });
|
|
485
|
+
throw err;
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
app.post("/apply/:runId", async (req, reply) => {
|
|
489
|
+
try {
|
|
490
|
+
return await service.apply(req.params.runId);
|
|
491
|
+
} catch (err) {
|
|
492
|
+
return reply.code(404).send({ error: err instanceof Error ? err.message : String(err) });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
app.get("/session/:id", async (req, reply) => {
|
|
496
|
+
const session = await service.getSession(req.params.id);
|
|
497
|
+
if (!session) return reply.code(404).send({ error: `Unknown session: ${req.params.id}` });
|
|
498
|
+
return session;
|
|
499
|
+
});
|
|
500
|
+
app.delete(
|
|
501
|
+
"/element/:sessionId/:elementId",
|
|
502
|
+
async (req) => {
|
|
503
|
+
const elementId = Number.parseInt(req.params.elementId, 10);
|
|
504
|
+
return service.removeElement(req.params.sessionId, elementId);
|
|
505
|
+
}
|
|
506
|
+
);
|
|
507
|
+
app.get("/ws", { websocket: true }, (socket) => {
|
|
508
|
+
const send = (event) => {
|
|
509
|
+
if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(event));
|
|
510
|
+
};
|
|
511
|
+
const unsubscribe = service.bus.subscribe(send);
|
|
512
|
+
socket.on("message", (raw) => {
|
|
513
|
+
let msg;
|
|
514
|
+
try {
|
|
515
|
+
msg = JSON.parse(raw.toString());
|
|
516
|
+
} catch {
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (msg.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
|
|
520
|
+
else if (msg.type === "subscribe") {
|
|
521
|
+
for (const event of service.bus.replay(msg.sessionId)) send(event);
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
socket.on("close", unsubscribe);
|
|
525
|
+
socket.on("error", unsubscribe);
|
|
526
|
+
});
|
|
527
|
+
return app;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export {
|
|
531
|
+
EventBus,
|
|
532
|
+
launchAgent,
|
|
533
|
+
enrichBundle,
|
|
534
|
+
RefusalError,
|
|
535
|
+
RunManager,
|
|
536
|
+
DaemonService,
|
|
537
|
+
NotFoundError,
|
|
538
|
+
buildServer
|
|
539
|
+
};
|
|
540
|
+
//# sourceMappingURL=chunk-FY7JGOX6.js.map
|