@zeroroot-ai/zerocool 0.4.0 → 0.5.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.
@@ -57,8 +57,10 @@
57
57
  */
58
58
  import { type AgentOutcome, type TaskHarness } from "@zeroroot-ai/sdk";
59
59
  import { type FindingsBackend } from "./findings.js";
60
+ import { type GitLabClient } from "./gitlab.js";
60
61
  import { runOpencode } from "./opencode-run.js";
61
62
  import type { SemgrepRunner } from "./semgrep.js";
63
+ import { type ScanLauncher, type WatchCheckpoints } from "./watch.js";
62
64
  import { type TriageModel } from "./source-analysis.js";
63
65
  /**
64
66
  * The per-dispatch grant, as the sandbox injects it. The child authenticates its
@@ -126,8 +128,30 @@ export interface DecodedTask {
126
128
  * value that is not a string is dropped rather than guessed at.
127
129
  */
128
130
  export declare function decodeTask(taskB64: string | undefined): DecodedTask;
131
+ /**
132
+ * Read one `Task.context` / `Task.metadata` entry as a string.
133
+ *
134
+ * Both maps are `map<string, gibson.common.v1.TypedValue>`, so canonical
135
+ * protojson renders every value as a one-key object naming the oneof arm —
136
+ * `{"stringValue":"source-analysis"}`, `{"intValue":"42"}` — never as a bare
137
+ * string. gibson marshals the Task with `protojson.Marshal` over
138
+ * `agent.TaskToProto`, which wraps each entry with `mapToTypedValueMap`, so
139
+ * this is the only shape a dispatched agent ever receives. Reading these maps
140
+ * as plain strings silently dropped every entry, which is why a mission node's
141
+ * `zerocool.task`, `repository.commit` and `target.id` never reached the run.
142
+ *
143
+ * A bare string is still accepted, because a hand-built Task written as plain
144
+ * JSON is the normal way to drive a dispatch by hand. Both snake_case and
145
+ * lowerCamel arm names are read: protojson emits lowerCamel by default but can
146
+ * be told to keep the proto field names, and a decoder that accepts only one of
147
+ * them fails on a Task that is otherwise valid.
148
+ *
149
+ * `nullValue` yields no entry: a null is the absence of a value, and rendering
150
+ * it as the string "null" would hand a task a target id of `"null"`.
151
+ */
152
+ export declare function typedValueString(val: unknown): string | undefined;
129
153
  /** The task kinds a sandboxed dispatch can serve. */
130
- export type DispatchTaskKind = "opencode" | "source-analysis";
154
+ export type DispatchTaskKind = "opencode" | "source-analysis" | "watch";
131
155
  /**
132
156
  * Which task this dispatch runs. The mission node says so in the Task's
133
157
  * context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
@@ -164,6 +188,20 @@ export interface DispatchDeps {
164
188
  harness?: (ctx: DispatchContext) => TaskHarness;
165
189
  /** Force the task kind, over the Task's own context. */
166
190
  taskKind?: DispatchTaskKind;
191
+ /** Watch seams: no GitLab, no World and no child missions in a test. */
192
+ watch?: WatchDeps;
193
+ }
194
+ /** The watch task's injectable seams. Everything the loop reaches out through. */
195
+ export interface WatchDeps {
196
+ gitlab?: GitLabClient;
197
+ checkpoints?: WatchCheckpoints;
198
+ scans?: ScanLauncher;
199
+ /** Resolve the GitLab token. Defaults to the harness `GetCredential`. */
200
+ credential?: (name: string) => Promise<string>;
201
+ sleep?: (ms: number) => Promise<void>;
202
+ /** Bound the loop. Unset runs until the sandbox is torn down. */
203
+ maxPolls?: number;
204
+ signal?: AbortSignal;
167
205
  }
168
206
  /**
169
207
  * Run one dispatched Task headless and map it to a terminal outcome.
@@ -185,6 +223,31 @@ export declare function runDispatch(ctx: DispatchContext, deps?: DispatchDeps):
185
223
  * `repository.url`, `repository.commit`, `target.id`.
186
224
  */
187
225
  export declare function runSourceAnalysisDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
226
+ /**
227
+ * The watch task, sandboxed (zerocool-plugins#88). The always-on shape: this
228
+ * dispatch does not finish until the mission is cancelled or the sandbox is
229
+ * torn down. It polls the application's GitLab project and originates a Scan
230
+ * mission per finished pipeline on the branch, from inside this very mission
231
+ * (ADR-0063). See `watch.ts` for why the agent polls rather than CI calling in.
232
+ *
233
+ * Task context keys it reads:
234
+ * `zerocool.task` selector, "watch"
235
+ * `application` the Application this watch follows (required)
236
+ * `gitlab.project` `group/project` on the instance (required)
237
+ * `gitlab.url` instance base url; defaults to gitlab.com
238
+ * `gitlab.ref` branch to watch; defaults to `main`
239
+ * `gitlab.credential` tenant secret holding the project access token
240
+ * `repository.url` clone url handed to each Scan mission
241
+ * `target.id` the registered target each Scan mission binds to
242
+ * `watch.interval_ms` poll cadence; defaults to 30s
243
+ */
244
+ export declare function runWatchDispatch(ctx: DispatchContext, deps?: DispatchDeps): Promise<AgentOutcome>;
245
+ /**
246
+ * Resolve a tenant secret through the harness on the dispatch grant. The value
247
+ * is returned to the caller and never logged: it is the GitLab token, and the
248
+ * console stream this dispatch writes is shown in the browser.
249
+ */
250
+ export declare function harnessCredential(harness: TaskHarness): (name: string) => Promise<string>;
188
251
  /**
189
252
  * Render a terminal outcome as the last NDJSON line on stdout.
190
253
  *
package/dist/dispatch.js CHANGED
@@ -58,7 +58,9 @@
58
58
  import { openTaskHarness } from "@zeroroot-ai/sdk";
59
59
  import { join } from "node:path";
60
60
  import { taskFindingsBackend } from "./findings.js";
61
+ import { gitlabRest } from "./gitlab.js";
61
62
  import { runOpencode } from "./opencode-run.js";
63
+ import { DEFAULT_REF, harnessScanLauncher, runWatchLoop, worldCheckpoints, } from "./watch.js";
62
64
  import { formatSourceAnalysis, harnessTriageModel, runSourceAnalysis, } from "./source-analysis.js";
63
65
  /**
64
66
  * Render the per-dispatch grant back to environment for the opencode child, so
@@ -118,11 +120,58 @@ function stringMap(v) {
118
120
  return {};
119
121
  const out = {};
120
122
  for (const [k, val] of Object.entries(v)) {
121
- if (typeof val === "string")
122
- out[k] = val;
123
+ const s = typedValueString(val);
124
+ if (s !== undefined)
125
+ out[k] = s;
123
126
  }
124
127
  return out;
125
128
  }
129
+ /**
130
+ * Read one `Task.context` / `Task.metadata` entry as a string.
131
+ *
132
+ * Both maps are `map<string, gibson.common.v1.TypedValue>`, so canonical
133
+ * protojson renders every value as a one-key object naming the oneof arm —
134
+ * `{"stringValue":"source-analysis"}`, `{"intValue":"42"}` — never as a bare
135
+ * string. gibson marshals the Task with `protojson.Marshal` over
136
+ * `agent.TaskToProto`, which wraps each entry with `mapToTypedValueMap`, so
137
+ * this is the only shape a dispatched agent ever receives. Reading these maps
138
+ * as plain strings silently dropped every entry, which is why a mission node's
139
+ * `zerocool.task`, `repository.commit` and `target.id` never reached the run.
140
+ *
141
+ * A bare string is still accepted, because a hand-built Task written as plain
142
+ * JSON is the normal way to drive a dispatch by hand. Both snake_case and
143
+ * lowerCamel arm names are read: protojson emits lowerCamel by default but can
144
+ * be told to keep the proto field names, and a decoder that accepts only one of
145
+ * them fails on a Task that is otherwise valid.
146
+ *
147
+ * `nullValue` yields no entry: a null is the absence of a value, and rendering
148
+ * it as the string "null" would hand a task a target id of `"null"`.
149
+ */
150
+ export function typedValueString(val) {
151
+ if (typeof val === "string")
152
+ return val;
153
+ if (!val || typeof val !== "object")
154
+ return undefined;
155
+ const v = val;
156
+ const arm = (camel, snake) => (v[camel] !== undefined ? v[camel] : v[snake]);
157
+ const s = arm("stringValue", "string_value");
158
+ if (typeof s === "string")
159
+ return s;
160
+ // protojson renders int64 and uint64 as strings, and double as a number.
161
+ const i = arm("intValue", "int_value");
162
+ if (typeof i === "string" || typeof i === "number")
163
+ return String(i);
164
+ const u = arm("uintValue", "uint_value");
165
+ if (typeof u === "string" || typeof u === "number")
166
+ return String(u);
167
+ const d = arm("doubleValue", "double_value");
168
+ if (typeof d === "number")
169
+ return String(d);
170
+ const b = arm("boolValue", "bool_value");
171
+ if (typeof b === "boolean")
172
+ return String(b);
173
+ return undefined;
174
+ }
126
175
  /**
127
176
  * Which task this dispatch runs. The mission node says so in the Task's
128
177
  * context (`zerocool.task`); ZEROCOOL_TASK forces it for a hand-run sandbox.
@@ -130,7 +179,11 @@ function stringMap(v) {
130
179
  */
131
180
  export function dispatchTaskKind(ctx, env = {}) {
132
181
  const kind = env.ZEROCOOL_TASK ?? ctx.taskContext["zerocool.task"] ?? "";
133
- return kind === "source-analysis" ? "source-analysis" : "opencode";
182
+ if (kind === "source-analysis")
183
+ return "source-analysis";
184
+ if (kind === "watch")
185
+ return "watch";
186
+ return "opencode";
134
187
  }
135
188
  /**
136
189
  * Read one sandboxed dispatch's context from the environment.
@@ -185,9 +238,13 @@ export function readDispatchContext(env, opts = {}) {
185
238
  * into a failed terminal result and a non-zero exit.
186
239
  */
187
240
  export async function runDispatch(ctx, deps = {}) {
188
- if ((deps.taskKind ?? dispatchTaskKind(ctx, process.env)) === "source-analysis") {
241
+ const kind = deps.taskKind ?? dispatchTaskKind(ctx, process.env);
242
+ if (kind === "source-analysis") {
189
243
  return runSourceAnalysisDispatch(ctx, deps);
190
244
  }
245
+ if (kind === "watch") {
246
+ return runWatchDispatch(ctx, deps);
247
+ }
191
248
  const run = deps.run ?? runOpencode;
192
249
  const result = await run({
193
250
  goal: ctx.goal,
@@ -264,6 +321,119 @@ export async function runSourceAnalysisDispatch(ctx, deps = {}) {
264
321
  harness?.stop();
265
322
  }
266
323
  }
324
+ /**
325
+ * The watch task, sandboxed (zerocool-plugins#88). The always-on shape: this
326
+ * dispatch does not finish until the mission is cancelled or the sandbox is
327
+ * torn down. It polls the application's GitLab project and originates a Scan
328
+ * mission per finished pipeline on the branch, from inside this very mission
329
+ * (ADR-0063). See `watch.ts` for why the agent polls rather than CI calling in.
330
+ *
331
+ * Task context keys it reads:
332
+ * `zerocool.task` selector, "watch"
333
+ * `application` the Application this watch follows (required)
334
+ * `gitlab.project` `group/project` on the instance (required)
335
+ * `gitlab.url` instance base url; defaults to gitlab.com
336
+ * `gitlab.ref` branch to watch; defaults to `main`
337
+ * `gitlab.credential` tenant secret holding the project access token
338
+ * `repository.url` clone url handed to each Scan mission
339
+ * `target.id` the registered target each Scan mission binds to
340
+ * `watch.interval_ms` poll cadence; defaults to 30s
341
+ */
342
+ export async function runWatchDispatch(ctx, deps = {}) {
343
+ const w = deps.watch ?? {};
344
+ const application = ctx.taskContext.application || "";
345
+ const projectPath = ctx.taskContext["gitlab.project"] || "";
346
+ const ref = ctx.taskContext["gitlab.ref"] || DEFAULT_REF;
347
+ const targetId = ctx.taskContext["target.id"] || "";
348
+ const credentialName = ctx.taskContext["gitlab.credential"] || "gitlab-token";
349
+ if (!application) {
350
+ throw new Error("watch: the mission node set no `application` in the task context. The watch keys its " +
351
+ "checkpoints and its Scan missions by Application; without one it cannot tell its own " +
352
+ "state from another watch's.");
353
+ }
354
+ if (!w.gitlab && !projectPath) {
355
+ throw new Error("watch: the mission node set no `gitlab.project` in the task context, so there is no " +
356
+ "project to poll.");
357
+ }
358
+ // A harness is opened unless every seam that would use one was injected.
359
+ const needsHarness = !w.gitlab || !w.checkpoints || !w.scans || !w.credential;
360
+ const opened = needsHarness && !deps.harness;
361
+ const harness = needsHarness
362
+ ? (deps.harness ??
363
+ ((c) => openTaskHarness({ endpoint: c.callbackEndpoint, token: c.callbackToken, insecure: c.insecure ?? false })))(ctx)
364
+ : undefined;
365
+ try {
366
+ const gitlab = w.gitlab ??
367
+ gitlabRest({
368
+ projectPath,
369
+ token: await (w.credential ?? harnessCredential(harness))(credentialName),
370
+ ...(ctx.taskContext["gitlab.url"] ? { baseUrl: ctx.taskContext["gitlab.url"] } : {}),
371
+ });
372
+ const checkpoints = w.checkpoints ?? worldCheckpoints(harness, application);
373
+ const scans = w.scans ??
374
+ harnessScanLauncher(harness, {
375
+ targetId,
376
+ inputs: {
377
+ application,
378
+ repositoryUrl: ctx.taskContext["repository.url"] || "",
379
+ ...(ctx.taskContext["image.ref"] ? { imageRef: ctx.taskContext["image.ref"] } : {}),
380
+ },
381
+ });
382
+ const intervalMs = Number(ctx.taskContext["watch.interval_ms"] || Number.NaN);
383
+ const summary = await runWatchLoop({
384
+ application,
385
+ ref,
386
+ gitlab,
387
+ checkpoints,
388
+ scans,
389
+ ...(Number.isFinite(intervalMs) && intervalMs > 0 ? { intervalMs } : {}),
390
+ ...(w.maxPolls !== undefined ? { maxPolls: w.maxPolls } : {}),
391
+ ...(w.signal ? { signal: w.signal } : {}),
392
+ ...(w.sleep ? { sleep: w.sleep } : {}),
393
+ ...(deps.onEvent ? { onEvent: (e) => deps.onEvent?.(JSON.stringify(e)) } : {}),
394
+ });
395
+ return {
396
+ output: `watch ${application}@${ref}: ${summary.polls} polls, ${summary.scans} scan missions, ` +
397
+ `${summary.errors} errors; last pipeline ${summary.lastPipelineId || "none"}`,
398
+ success: true,
399
+ metadata: {
400
+ task: "watch",
401
+ application,
402
+ ref,
403
+ polls: String(summary.polls),
404
+ scans: String(summary.scans),
405
+ errors: String(summary.errors),
406
+ last_pipeline_id: String(summary.lastPipelineId),
407
+ },
408
+ };
409
+ }
410
+ finally {
411
+ if (opened)
412
+ harness?.stop();
413
+ }
414
+ }
415
+ /**
416
+ * Resolve a tenant secret through the harness on the dispatch grant. The value
417
+ * is returned to the caller and never logged: it is the GitLab token, and the
418
+ * console stream this dispatch writes is shown in the browser.
419
+ */
420
+ export function harnessCredential(harness) {
421
+ return async (name) => {
422
+ const res = await harness.client.getCredential({ context: harness.context, name });
423
+ if (res.error) {
424
+ throw new Error(`GetCredential(${name}) refused: ${res.error.message}`);
425
+ }
426
+ const secret = res.credential?.secretData;
427
+ const value = secret?.case === "apiKey" || secret?.case === "bearerToken" || secret?.case === "customSecret"
428
+ ? secret.value
429
+ : undefined;
430
+ if (!value) {
431
+ throw new Error(`GetCredential(${name}) returned no usable secret. A GitLab project access token is ` +
432
+ "stored as an api key, a bearer token, or a single-valued custom secret.");
433
+ }
434
+ return value;
435
+ };
436
+ }
267
437
  /**
268
438
  * Render a terminal outcome as the last NDJSON line on stdout.
269
439
  *
@@ -0,0 +1,74 @@
1
+ /**
2
+ * GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
3
+ *
4
+ * The agent polls one project's pipelines on one branch. It never receives a
5
+ * webhook and GitLab holds no Gibson credential: the poll runs outbound from
6
+ * the sandbox on the tenant's own project access token, resolved through the
7
+ * harness `GetCredential` under the dispatch grant. That direction is the point
8
+ * — a CI job cannot originate a Gibson mission (ADR-0063 admits a component
9
+ * only from inside a mission it was dispatched to), so the agent watches
10
+ * GitLab rather than GitLab calling Gibson.
11
+ *
12
+ * FINISHED MEANS `success`. A pipeline that failed published no image and left
13
+ * `main` unchanged, so there is nothing new to scan; treating it as a trigger
14
+ * would run a Scan mission against the image the previous pipeline built and
15
+ * report its findings against the new commit. `canceled` and `skipped` are the
16
+ * same case. The status this polls for is therefore `success`, named once in
17
+ * {@link TRIGGER_STATUS} rather than spelled at each call site.
18
+ *
19
+ * Everything here is one `fetch` away from pure: {@link gitlabRest} takes the
20
+ * fetch it uses, so `watch.test.ts` drives a whole loop against a stub with no
21
+ * network and no token.
22
+ */
23
+ /** The pipeline status that triggers a Scan mission. See the module note. */
24
+ export declare const TRIGGER_STATUS = "success";
25
+ /** GitLab's public host, when a tenant names no other instance. */
26
+ export declare const GITLAB_DEFAULT_URL = "https://gitlab.com";
27
+ /** One pipeline, reduced to what a trigger decision and a Scan mission need. */
28
+ export interface Pipeline {
29
+ /** GitLab's own pipeline id. Monotonic per project, and the checkpoint key. */
30
+ id: number;
31
+ /** The commit the pipeline ran on — the Scan mission's `repository.commit`. */
32
+ sha: string;
33
+ /** `success` for anything this module returns; carried for the console line. */
34
+ status: string;
35
+ /** The branch, echoed back so a caller can log what it matched. */
36
+ ref: string;
37
+ /** GitLab's page for the pipeline, for the console line and the MR note. */
38
+ webUrl: string;
39
+ }
40
+ /** The GitLab reads the watch loop makes. One method, so a stub is three lines. */
41
+ export interface GitLabClient {
42
+ /**
43
+ * The most recent successful pipeline on `ref`, or `undefined` when the
44
+ * project has none. Rejects when GitLab refuses or is unreachable — the loop
45
+ * reports that and keeps polling.
46
+ */
47
+ latestFinishedPipeline(ref: string): Promise<Pipeline | undefined>;
48
+ }
49
+ /** What {@link gitlabRest} needs to reach one project. */
50
+ export interface GitLabRestOptions {
51
+ /** `group/project`, unencoded. Encoded once here, at the call. */
52
+ projectPath: string;
53
+ /** The project access token, resolved from the tenant secret store. */
54
+ token: string;
55
+ /** The instance. Defaults to {@link GITLAB_DEFAULT_URL}. */
56
+ baseUrl?: string;
57
+ /** Injectable transport. Defaults to the global `fetch`. */
58
+ fetch?: typeof globalThis.fetch;
59
+ }
60
+ /**
61
+ * Read one pipeline out of GitLab's JSON. A row without an id or a sha is
62
+ * dropped rather than guessed at: a Scan mission keyed on a missing commit
63
+ * would scan whatever the workspace happened to hold.
64
+ */
65
+ export declare function parsePipeline(raw: unknown): Pipeline | undefined;
66
+ /**
67
+ * A {@link GitLabClient} over GitLab's REST API.
68
+ *
69
+ * The token travels in `PRIVATE-TOKEN`, which is what a project access token
70
+ * authenticates with. It is never logged and never placed in a URL, so it
71
+ * cannot reach the console stream through an error message that quotes the
72
+ * request.
73
+ */
74
+ export declare function gitlabRest(opts: GitLabRestOptions): GitLabClient;
package/dist/gitlab.js ADDED
@@ -0,0 +1,82 @@
1
+ /**
2
+ * GitLab — the trigger the always-on agent watches (zerocool-plugins#88).
3
+ *
4
+ * The agent polls one project's pipelines on one branch. It never receives a
5
+ * webhook and GitLab holds no Gibson credential: the poll runs outbound from
6
+ * the sandbox on the tenant's own project access token, resolved through the
7
+ * harness `GetCredential` under the dispatch grant. That direction is the point
8
+ * — a CI job cannot originate a Gibson mission (ADR-0063 admits a component
9
+ * only from inside a mission it was dispatched to), so the agent watches
10
+ * GitLab rather than GitLab calling Gibson.
11
+ *
12
+ * FINISHED MEANS `success`. A pipeline that failed published no image and left
13
+ * `main` unchanged, so there is nothing new to scan; treating it as a trigger
14
+ * would run a Scan mission against the image the previous pipeline built and
15
+ * report its findings against the new commit. `canceled` and `skipped` are the
16
+ * same case. The status this polls for is therefore `success`, named once in
17
+ * {@link TRIGGER_STATUS} rather than spelled at each call site.
18
+ *
19
+ * Everything here is one `fetch` away from pure: {@link gitlabRest} takes the
20
+ * fetch it uses, so `watch.test.ts` drives a whole loop against a stub with no
21
+ * network and no token.
22
+ */
23
+ /** The pipeline status that triggers a Scan mission. See the module note. */
24
+ export const TRIGGER_STATUS = "success";
25
+ /** GitLab's public host, when a tenant names no other instance. */
26
+ export const GITLAB_DEFAULT_URL = "https://gitlab.com";
27
+ /**
28
+ * Read one pipeline out of GitLab's JSON. A row without an id or a sha is
29
+ * dropped rather than guessed at: a Scan mission keyed on a missing commit
30
+ * would scan whatever the workspace happened to hold.
31
+ */
32
+ export function parsePipeline(raw) {
33
+ if (!raw || typeof raw !== "object")
34
+ return undefined;
35
+ const p = raw;
36
+ const id = typeof p.id === "number" ? p.id : Number.NaN;
37
+ const sha = typeof p.sha === "string" ? p.sha : "";
38
+ if (!Number.isInteger(id) || id <= 0 || !sha)
39
+ return undefined;
40
+ return {
41
+ id,
42
+ sha,
43
+ status: typeof p.status === "string" ? p.status : "",
44
+ ref: typeof p.ref === "string" ? p.ref : "",
45
+ webUrl: typeof p.web_url === "string" ? p.web_url : "",
46
+ };
47
+ }
48
+ /**
49
+ * A {@link GitLabClient} over GitLab's REST API.
50
+ *
51
+ * The token travels in `PRIVATE-TOKEN`, which is what a project access token
52
+ * authenticates with. It is never logged and never placed in a URL, so it
53
+ * cannot reach the console stream through an error message that quotes the
54
+ * request.
55
+ */
56
+ export function gitlabRest(opts) {
57
+ const base = (opts.baseUrl || GITLAB_DEFAULT_URL).replace(/\/+$/, "");
58
+ const project = encodeURIComponent(opts.projectPath);
59
+ const doFetch = opts.fetch ?? globalThis.fetch;
60
+ return {
61
+ async latestFinishedPipeline(ref) {
62
+ const url = `${base}/api/v4/projects/${project}/pipelines` +
63
+ `?ref=${encodeURIComponent(ref)}&status=${TRIGGER_STATUS}&order_by=id&sort=desc&per_page=1`;
64
+ const res = await doFetch(url, {
65
+ headers: { "PRIVATE-TOKEN": opts.token, Accept: "application/json" },
66
+ });
67
+ if (!res.ok) {
68
+ throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${res.status} ${res.statusText}`);
69
+ }
70
+ const body = await res.json();
71
+ if (!Array.isArray(body)) {
72
+ throw new Error(`GitLab pipelines for ${opts.projectPath}@${ref} returned ${typeof body}, expected an array`);
73
+ }
74
+ for (const row of body) {
75
+ const p = parsePipeline(row);
76
+ if (p)
77
+ return p;
78
+ }
79
+ return undefined;
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,214 @@
1
+ import { type TaskHarness } from "@zeroroot-ai/sdk";
2
+ import { type GitLabClient, type Pipeline } from "./gitlab.js";
3
+ /**
4
+ * The always-on watch loop (zerocool-plugins#88).
5
+ *
6
+ * Dispatched into a `watch-<application>` mission, the agent does not run a
7
+ * task and exit. It polls the application's GitLab project every 30 seconds,
8
+ * and on each finished pipeline on `main` it has not seen it originates a Scan
9
+ * mission, waits for it, and goes back to polling. The loop ends when the
10
+ * mission is cancelled or the sandbox is torn down, not on its own.
11
+ *
12
+ * WHY THE AGENT POLLS RATHER THAN CI CALLING GIBSON. ADR-0063 lets a component
13
+ * originate a mission only from inside one it was dispatched to. A GitLab job
14
+ * is not inside a mission, so it cannot originate a Scan mission whatever
15
+ * credential it holds. The person originates the long-lived watch mission once;
16
+ * every Scan mission after that is originated by the agent from inside it,
17
+ * through `HarnessCallbackService.CreateMission` on the dispatch grant. Since
18
+ * gibson#1657 that RPC takes its parent, tenant and lineage from the resolved
19
+ * harness rather than the request, so the child is attributed to this mission
20
+ * and a caller outside a live mission is refused outright.
21
+ *
22
+ * ONLY THE LATEST PIPELINE IS EVER CONSIDERED. The loop asks GitLab for the
23
+ * most recent successful pipeline on the branch — never for a backlog. That is
24
+ * what makes the checkpoint safe to lose: an unreadable checkpoint costs one
25
+ * redundant Scan of the current head, never a stampede over a project's
26
+ * history. A checkpoint is an optimisation against duplicate work, not a
27
+ * correctness gate, and the loop is written so that is true.
28
+ *
29
+ * THE CHECKPOINT IS AN OBSERVATION, NOT A FILE. The sandbox is ephemeral, so
30
+ * anything written to its disk dies with it. The last pipeline seen goes into
31
+ * the World as a memory observation under the dispatch grant, which is the same
32
+ * write path every other thing the agent learns takes.
33
+ */
34
+ /** The observation `kind` the checkpoint is written under. */
35
+ export declare const CHECKPOINT_KIND = "watch-checkpoint";
36
+ /** Default poll interval. The issue's cadence, and slow enough for GitLab. */
37
+ export declare const DEFAULT_INTERVAL_MS = 30000;
38
+ /** Default ceiling on one Scan mission before the loop stops waiting on it. */
39
+ export declare const DEFAULT_SCAN_TIMEOUT_MS: number;
40
+ /** The branch a watch follows unless the task says otherwise. */
41
+ export declare const DEFAULT_REF = "main";
42
+ /**
43
+ * Poll state, kept in the World so a restarted agent resumes.
44
+ *
45
+ * `last` returning 0 means "nothing recorded, or the record could not be read".
46
+ * Both are the same instruction to the loop — scan the current head once — so
47
+ * there is nothing here for a caller to distinguish or get wrong.
48
+ */
49
+ export interface WatchCheckpoints {
50
+ last(): Promise<number>;
51
+ record(p: Pipeline): Promise<void>;
52
+ }
53
+ /** Originates and awaits one Scan mission. */
54
+ export interface ScanLauncher {
55
+ /** Create and run the Scan mission for `p`. Returns its mission id. */
56
+ launch(p: Pipeline): Promise<string>;
57
+ /** Block until that mission is terminal. Returns its status, for the console. */
58
+ wait(missionId: string): Promise<string>;
59
+ }
60
+ /** One line of the console stream. Rendered as NDJSON by the dispatch driver. */
61
+ export type WatchEvent = {
62
+ type: "watch.start";
63
+ application: string;
64
+ ref: string;
65
+ lastPipelineId: number;
66
+ } | {
67
+ type: "watch.poll";
68
+ application: string;
69
+ ref: string;
70
+ pipelineId: number;
71
+ status: string;
72
+ fresh: boolean;
73
+ } | {
74
+ type: "watch.scan";
75
+ application: string;
76
+ pipelineId: number;
77
+ commit: string;
78
+ missionId: string;
79
+ } | {
80
+ type: "watch.scan.done";
81
+ application: string;
82
+ pipelineId: number;
83
+ missionId: string;
84
+ status: string;
85
+ } | {
86
+ type: "watch.error";
87
+ application: string;
88
+ stage: "checkpoint" | "poll" | "scan" | "record";
89
+ message: string;
90
+ };
91
+ /** What one watch run reports back when it is asked to stop. */
92
+ export interface WatchSummary {
93
+ polls: number;
94
+ scans: number;
95
+ errors: number;
96
+ lastPipelineId: number;
97
+ }
98
+ export interface WatchLoopOptions {
99
+ /** The Application this watch follows, e.g. `customer-portal`. */
100
+ application: string;
101
+ /** The branch whose pipelines trigger a Scan. */
102
+ ref?: string;
103
+ gitlab: GitLabClient;
104
+ checkpoints: WatchCheckpoints;
105
+ scans: ScanLauncher;
106
+ /** Poll cadence. Defaults to {@link DEFAULT_INTERVAL_MS}. */
107
+ intervalMs?: number;
108
+ /** Stop after this many polls. Unset runs until aborted — the real posture. */
109
+ maxPolls?: number;
110
+ /** Cooperative stop, so a caller can end the loop between polls. */
111
+ signal?: AbortSignal;
112
+ /** Console stream. Every poll emits one line so the ops wall shows life. */
113
+ onEvent?: (e: WatchEvent) => void;
114
+ /** Test seams. */
115
+ sleep?: (ms: number) => Promise<void>;
116
+ }
117
+ /**
118
+ * Run the watch loop.
119
+ *
120
+ * Nothing in here throws for an expected failure. A GitLab outage, a refused
121
+ * Scan, a checkpoint that cannot be written — each is reported to the console
122
+ * and the loop keeps polling, because an always-on agent that exits on the
123
+ * first transient error is not always-on. The one thing it will not do is
124
+ * record a checkpoint for a Scan it failed to originate: that pipeline is
125
+ * retried on the next poll.
126
+ */
127
+ export declare function runWatchLoop(opts: WatchLoopOptions): Promise<WatchSummary>;
128
+ /**
129
+ * The checkpoint fact, in one parseable line.
130
+ *
131
+ * A memory is free text by design, so the loop writes a line it can read back
132
+ * without depending on which properties the projector chose to keep. The
133
+ * application is part of the line, so one tenant's several watches never read
134
+ * each other's checkpoints.
135
+ */
136
+ export declare function checkpointText(application: string, p: Pipeline): string;
137
+ /** The checkpoint a line carries, or `undefined` when it is not one. */
138
+ export declare function parseCheckpoint(text: string): {
139
+ application: string;
140
+ pipelineId: number;
141
+ commit: string;
142
+ } | undefined;
143
+ /**
144
+ * Checkpoints kept in the tenant World under the dispatch grant.
145
+ *
146
+ * The write is `Observe` with a memory shape, which the Taxonomy gate lands as
147
+ * an `Observation` node. The read is the graph query for those observations.
148
+ *
149
+ * DAEMON STATE, and why the loop is built to survive it: the SDK records that
150
+ * `QueryNodes` is declared but not wired at the daemon's registration site and
151
+ * answers `Unimplemented` on a live cluster (gibson#1186). Until that seam is
152
+ * wired, `last` reports 0 and the loop scans the current head once per restart
153
+ * rather than resuming — which is the same behaviour as a first run, and is why
154
+ * the loop never reads a backlog.
155
+ */
156
+ export declare function worldCheckpoints(harness: TaskHarness, application: string): WatchCheckpoints;
157
+ /** The recall text for an application's checkpoints. Exported so a test names it once. */
158
+ export declare function checkpointQueryText(application: string): string;
159
+ /** What a Scan mission needs to know about the pipeline that triggered it. */
160
+ export interface ScanMissionInputs {
161
+ application: string;
162
+ pipeline: Pipeline;
163
+ /** The project's clone url, so the Scan mission knows what to check out. */
164
+ repositoryUrl: string;
165
+ /** The image the pipeline built, when the watch was told how to name it. */
166
+ imageRef?: string;
167
+ /** The registered target the runtime scans are allowed to reach. */
168
+ targetId?: string;
169
+ }
170
+ /**
171
+ * The Scan mission definition, as canonical proto JSON.
172
+ *
173
+ * A PLACEHOLDER WITH A REAL SHAPE. gibson#1660 owns the Scan mission proper —
174
+ * Trivy on the image, source analysis on the commit, and the runtime tools
175
+ * against the target, fanned out and joined. Until it lands, this originates
176
+ * the one node that exists today: zerocool's source-analysis task
177
+ * (zerocool-plugins#87) on the pipeline's commit. Everything the full fan-out
178
+ * needs is already carried in the node's task context, so gibson#1660 replaces
179
+ * the body of this one function and nothing else in the loop changes.
180
+ *
181
+ * The definition is built as protojson by hand rather than through the
182
+ * generated schema because the SDK's package exports only its index, and the
183
+ * mission schema is not re-exported from it.
184
+ */
185
+ export declare function scanMissionDefinition(inputs: ScanMissionInputs): unknown;
186
+ /** The child mission's name, which is also how a person finds it in the dashboard. */
187
+ export declare function scanMissionName(inputs: ScanMissionInputs): string;
188
+ /**
189
+ * Render a string map as `map<string, gibson.common.v1.TypedValue>` in
190
+ * protojson. Every value the loop passes is a string, so every entry is a
191
+ * `stringValue`.
192
+ */
193
+ export declare function typedContext(ctx: Record<string, string>): Record<string, {
194
+ stringValue: string;
195
+ }>;
196
+ export interface HarnessScanLauncherOptions {
197
+ /** Everything but the pipeline, which the launcher fills in per trigger. */
198
+ inputs: Omit<ScanMissionInputs, "pipeline">;
199
+ /** The target the child mission binds to. Its scope for every observation. */
200
+ targetId: string;
201
+ /** Ceiling on one Scan. Defaults to {@link DEFAULT_SCAN_TIMEOUT_MS}. */
202
+ scanTimeoutMs?: number;
203
+ /** Override the definition, so gibson#1660 can supply the real one. */
204
+ definition?: (inputs: ScanMissionInputs) => unknown;
205
+ }
206
+ /**
207
+ * A {@link ScanLauncher} over the harness callbacks, on the dispatch grant.
208
+ *
209
+ * `CreateMission` here is the in-mission origination ADR-0063 permits: the
210
+ * daemon resolves the parent from this harness, so the Scan mission is a child
211
+ * of the watch mission and inherits its tenant. Nothing in the request names a
212
+ * parent, a tenant or an originator, so nothing here can widen them.
213
+ */
214
+ export declare function harnessScanLauncher(harness: TaskHarness, opts: HarnessScanLauncherOptions): ScanLauncher;
package/dist/watch.js ADDED
@@ -0,0 +1,341 @@
1
+ import { remember, taskKnowledge } from "@zeroroot-ai/sdk";
2
+ import { TRIGGER_STATUS } from "./gitlab.js";
3
+ /**
4
+ * The always-on watch loop (zerocool-plugins#88).
5
+ *
6
+ * Dispatched into a `watch-<application>` mission, the agent does not run a
7
+ * task and exit. It polls the application's GitLab project every 30 seconds,
8
+ * and on each finished pipeline on `main` it has not seen it originates a Scan
9
+ * mission, waits for it, and goes back to polling. The loop ends when the
10
+ * mission is cancelled or the sandbox is torn down, not on its own.
11
+ *
12
+ * WHY THE AGENT POLLS RATHER THAN CI CALLING GIBSON. ADR-0063 lets a component
13
+ * originate a mission only from inside one it was dispatched to. A GitLab job
14
+ * is not inside a mission, so it cannot originate a Scan mission whatever
15
+ * credential it holds. The person originates the long-lived watch mission once;
16
+ * every Scan mission after that is originated by the agent from inside it,
17
+ * through `HarnessCallbackService.CreateMission` on the dispatch grant. Since
18
+ * gibson#1657 that RPC takes its parent, tenant and lineage from the resolved
19
+ * harness rather than the request, so the child is attributed to this mission
20
+ * and a caller outside a live mission is refused outright.
21
+ *
22
+ * ONLY THE LATEST PIPELINE IS EVER CONSIDERED. The loop asks GitLab for the
23
+ * most recent successful pipeline on the branch — never for a backlog. That is
24
+ * what makes the checkpoint safe to lose: an unreadable checkpoint costs one
25
+ * redundant Scan of the current head, never a stampede over a project's
26
+ * history. A checkpoint is an optimisation against duplicate work, not a
27
+ * correctness gate, and the loop is written so that is true.
28
+ *
29
+ * THE CHECKPOINT IS AN OBSERVATION, NOT A FILE. The sandbox is ephemeral, so
30
+ * anything written to its disk dies with it. The last pipeline seen goes into
31
+ * the World as a memory observation under the dispatch grant, which is the same
32
+ * write path every other thing the agent learns takes.
33
+ */
34
+ /** The observation `kind` the checkpoint is written under. */
35
+ export const CHECKPOINT_KIND = "watch-checkpoint";
36
+ /** Default poll interval. The issue's cadence, and slow enough for GitLab. */
37
+ export const DEFAULT_INTERVAL_MS = 30_000;
38
+ /** Default ceiling on one Scan mission before the loop stops waiting on it. */
39
+ export const DEFAULT_SCAN_TIMEOUT_MS = 30 * 60_000;
40
+ /** The branch a watch follows unless the task says otherwise. */
41
+ export const DEFAULT_REF = "main";
42
+ const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
43
+ /**
44
+ * Run the watch loop.
45
+ *
46
+ * Nothing in here throws for an expected failure. A GitLab outage, a refused
47
+ * Scan, a checkpoint that cannot be written — each is reported to the console
48
+ * and the loop keeps polling, because an always-on agent that exits on the
49
+ * first transient error is not always-on. The one thing it will not do is
50
+ * record a checkpoint for a Scan it failed to originate: that pipeline is
51
+ * retried on the next poll.
52
+ */
53
+ export async function runWatchLoop(opts) {
54
+ const ref = opts.ref || DEFAULT_REF;
55
+ const interval = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
56
+ const sleep = opts.sleep ?? defaultSleep;
57
+ const emit = (e) => opts.onEvent?.(e);
58
+ const summary = { polls: 0, scans: 0, errors: 0, lastPipelineId: 0 };
59
+ // A checkpoint that cannot be read is not fatal: the loop then treats the
60
+ // current head as fresh and scans it once. See the module note.
61
+ try {
62
+ summary.lastPipelineId = await opts.checkpoints.last();
63
+ }
64
+ catch (e) {
65
+ summary.errors++;
66
+ emit({ type: "watch.error", application: opts.application, stage: "checkpoint", message: message(e) });
67
+ }
68
+ emit({
69
+ type: "watch.start",
70
+ application: opts.application,
71
+ ref,
72
+ lastPipelineId: summary.lastPipelineId,
73
+ });
74
+ while (!opts.signal?.aborted && (opts.maxPolls === undefined || summary.polls < opts.maxPolls)) {
75
+ summary.polls++;
76
+ let pipeline;
77
+ try {
78
+ pipeline = await opts.gitlab.latestFinishedPipeline(ref);
79
+ }
80
+ catch (e) {
81
+ summary.errors++;
82
+ emit({ type: "watch.error", application: opts.application, stage: "poll", message: message(e) });
83
+ await pause();
84
+ continue;
85
+ }
86
+ const fresh = !!pipeline && pipeline.id > summary.lastPipelineId;
87
+ emit({
88
+ type: "watch.poll",
89
+ application: opts.application,
90
+ ref,
91
+ pipelineId: pipeline?.id ?? 0,
92
+ status: pipeline?.status ?? TRIGGER_STATUS,
93
+ fresh,
94
+ });
95
+ if (!pipeline || !fresh) {
96
+ await pause();
97
+ continue;
98
+ }
99
+ // Originate, then record, then wait. Recording before the wait means a
100
+ // sandbox that dies mid-Scan does not re-originate the same Scan on
101
+ // restart; recording only after a successful launch means a refused launch
102
+ // is retried on the next poll instead of being silently skipped.
103
+ let missionId;
104
+ try {
105
+ missionId = await opts.scans.launch(pipeline);
106
+ }
107
+ catch (e) {
108
+ summary.errors++;
109
+ emit({ type: "watch.error", application: opts.application, stage: "scan", message: message(e) });
110
+ await pause();
111
+ continue;
112
+ }
113
+ summary.scans++;
114
+ emit({
115
+ type: "watch.scan",
116
+ application: opts.application,
117
+ pipelineId: pipeline.id,
118
+ commit: pipeline.sha,
119
+ missionId,
120
+ });
121
+ try {
122
+ await opts.checkpoints.record(pipeline);
123
+ }
124
+ catch (e) {
125
+ summary.errors++;
126
+ emit({ type: "watch.error", application: opts.application, stage: "record", message: message(e) });
127
+ }
128
+ summary.lastPipelineId = pipeline.id;
129
+ try {
130
+ const status = await opts.scans.wait(missionId);
131
+ emit({
132
+ type: "watch.scan.done",
133
+ application: opts.application,
134
+ pipelineId: pipeline.id,
135
+ missionId,
136
+ status,
137
+ });
138
+ }
139
+ catch (e) {
140
+ summary.errors++;
141
+ emit({ type: "watch.error", application: opts.application, stage: "scan", message: message(e) });
142
+ }
143
+ await pause();
144
+ }
145
+ return summary;
146
+ async function pause() {
147
+ if (opts.signal?.aborted)
148
+ return;
149
+ if (opts.maxPolls !== undefined && summary.polls >= opts.maxPolls)
150
+ return;
151
+ await sleep(interval);
152
+ }
153
+ }
154
+ function message(e) {
155
+ return e instanceof Error ? e.message : String(e);
156
+ }
157
+ // ---------------------------------------------------------------------------
158
+ // The World-backed checkpoint
159
+ // ---------------------------------------------------------------------------
160
+ /**
161
+ * The checkpoint fact, in one parseable line.
162
+ *
163
+ * A memory is free text by design, so the loop writes a line it can read back
164
+ * without depending on which properties the projector chose to keep. The
165
+ * application is part of the line, so one tenant's several watches never read
166
+ * each other's checkpoints.
167
+ */
168
+ export function checkpointText(application, p) {
169
+ return `${CHECKPOINT_KIND} application=${application} pipeline=${p.id} commit=${p.sha}`;
170
+ }
171
+ /** The checkpoint a line carries, or `undefined` when it is not one. */
172
+ export function parseCheckpoint(text) {
173
+ if (!text.startsWith(CHECKPOINT_KIND))
174
+ return undefined;
175
+ const application = /\bapplication=(\S+)/.exec(text)?.[1] ?? "";
176
+ const pipelineId = Number(/\bpipeline=(\d+)/.exec(text)?.[1] ?? Number.NaN);
177
+ const commit = /\bcommit=(\S+)/.exec(text)?.[1] ?? "";
178
+ if (!application || !Number.isInteger(pipelineId) || pipelineId <= 0)
179
+ return undefined;
180
+ return { application, pipelineId, commit };
181
+ }
182
+ /**
183
+ * Checkpoints kept in the tenant World under the dispatch grant.
184
+ *
185
+ * The write is `Observe` with a memory shape, which the Taxonomy gate lands as
186
+ * an `Observation` node. The read is the graph query for those observations.
187
+ *
188
+ * DAEMON STATE, and why the loop is built to survive it: the SDK records that
189
+ * `QueryNodes` is declared but not wired at the daemon's registration site and
190
+ * answers `Unimplemented` on a live cluster (gibson#1186). Until that seam is
191
+ * wired, `last` reports 0 and the loop scans the current head once per restart
192
+ * rather than resuming — which is the same behaviour as a first run, and is why
193
+ * the loop never reads a backlog.
194
+ */
195
+ export function worldCheckpoints(harness, application) {
196
+ const knowledge = taskKnowledge(harness);
197
+ return {
198
+ async last() {
199
+ const hits = await knowledge.query({
200
+ text: checkpointQueryText(application),
201
+ nodeTypes: ["Observation"],
202
+ topK: 50,
203
+ });
204
+ let max = 0;
205
+ for (const hit of hits) {
206
+ const parsed = parseCheckpoint(String(hit.content ?? ""));
207
+ if (parsed && parsed.application === application && parsed.pipelineId > max) {
208
+ max = parsed.pipelineId;
209
+ }
210
+ }
211
+ return max;
212
+ },
213
+ record: (p) => remember(harness, {
214
+ text: checkpointText(application, p),
215
+ kind: CHECKPOINT_KIND,
216
+ tags: ["watch", `application:${application}`, `pipeline:${p.id}`],
217
+ sourceRef: p.webUrl,
218
+ }),
219
+ };
220
+ }
221
+ /** The recall text for an application's checkpoints. Exported so a test names it once. */
222
+ export function checkpointQueryText(application) {
223
+ return `${CHECKPOINT_KIND} application=${application}`;
224
+ }
225
+ /**
226
+ * The Scan mission definition, as canonical proto JSON.
227
+ *
228
+ * A PLACEHOLDER WITH A REAL SHAPE. gibson#1660 owns the Scan mission proper —
229
+ * Trivy on the image, source analysis on the commit, and the runtime tools
230
+ * against the target, fanned out and joined. Until it lands, this originates
231
+ * the one node that exists today: zerocool's source-analysis task
232
+ * (zerocool-plugins#87) on the pipeline's commit. Everything the full fan-out
233
+ * needs is already carried in the node's task context, so gibson#1660 replaces
234
+ * the body of this one function and nothing else in the loop changes.
235
+ *
236
+ * The definition is built as protojson by hand rather than through the
237
+ * generated schema because the SDK's package exports only its index, and the
238
+ * mission schema is not re-exported from it.
239
+ */
240
+ export function scanMissionDefinition(inputs) {
241
+ const { application, pipeline } = inputs;
242
+ const context = {
243
+ "zerocool.task": "source-analysis",
244
+ application,
245
+ "pipeline.id": String(pipeline.id),
246
+ "pipeline.url": pipeline.webUrl,
247
+ "repository.url": inputs.repositoryUrl,
248
+ "repository.ref": pipeline.ref,
249
+ "repository.commit": pipeline.sha,
250
+ ...(inputs.imageRef ? { "image.ref": inputs.imageRef } : {}),
251
+ ...(inputs.targetId ? { "target.id": inputs.targetId } : {}),
252
+ };
253
+ return {
254
+ name: scanMissionName(inputs),
255
+ description: `Scan ${application} at ${pipeline.sha.slice(0, 12)}, originated by the always-on agent ` +
256
+ `for pipeline ${pipeline.id}.`,
257
+ version: "1.0.0",
258
+ nodes: {
259
+ scan: {
260
+ id: "scan",
261
+ type: "NODE_TYPE_AGENT",
262
+ name: "scan",
263
+ description: "Source analysis of the commit the pipeline built.",
264
+ agentConfig: {
265
+ agentName: "zerocool",
266
+ task: {
267
+ id: "scan",
268
+ goal: `Scan the ${application} repository at commit ${pipeline.sha} and submit a Finding for ` +
269
+ "every real weakness you can support with evidence.",
270
+ context: typedContext(context),
271
+ },
272
+ },
273
+ },
274
+ },
275
+ entryPoints: ["scan"],
276
+ };
277
+ }
278
+ /** The child mission's name, which is also how a person finds it in the dashboard. */
279
+ export function scanMissionName(inputs) {
280
+ return `scan ${inputs.application} pipeline ${inputs.pipeline.id}`;
281
+ }
282
+ /**
283
+ * Render a string map as `map<string, gibson.common.v1.TypedValue>` in
284
+ * protojson. Every value the loop passes is a string, so every entry is a
285
+ * `stringValue`.
286
+ */
287
+ export function typedContext(ctx) {
288
+ const out = {};
289
+ for (const [k, v] of Object.entries(ctx)) {
290
+ if (v !== "")
291
+ out[k] = { stringValue: v };
292
+ }
293
+ return out;
294
+ }
295
+ /**
296
+ * A {@link ScanLauncher} over the harness callbacks, on the dispatch grant.
297
+ *
298
+ * `CreateMission` here is the in-mission origination ADR-0063 permits: the
299
+ * daemon resolves the parent from this harness, so the Scan mission is a child
300
+ * of the watch mission and inherits its tenant. Nothing in the request names a
301
+ * parent, a tenant or an originator, so nothing here can widen them.
302
+ */
303
+ export function harnessScanLauncher(harness, opts) {
304
+ const build = opts.definition ?? scanMissionDefinition;
305
+ const timeoutMs = BigInt(opts.scanTimeoutMs ?? DEFAULT_SCAN_TIMEOUT_MS);
306
+ return {
307
+ async launch(pipeline) {
308
+ const inputs = { ...opts.inputs, pipeline, targetId: opts.targetId };
309
+ const created = await harness.client.createMission({
310
+ context: harness.context,
311
+ missionDefinitionJson: new TextEncoder().encode(JSON.stringify(build(inputs))),
312
+ targetId: opts.targetId,
313
+ name: scanMissionName(inputs),
314
+ });
315
+ if (created.error) {
316
+ throw new Error(`CreateMission refused: ${created.error.message}`);
317
+ }
318
+ const missionId = created.mission?.id ?? "";
319
+ if (!missionId) {
320
+ throw new Error("CreateMission returned no mission id");
321
+ }
322
+ const run = await harness.client.runMission({
323
+ context: harness.context,
324
+ missionId,
325
+ wait: false,
326
+ timeoutMs: 0n,
327
+ });
328
+ if (run.error) {
329
+ throw new Error(`RunMission refused for ${missionId}: ${run.error.message}`);
330
+ }
331
+ return missionId;
332
+ },
333
+ async wait(missionId) {
334
+ const res = await harness.client.waitForMission({ context: harness.context, missionId, timeoutMs });
335
+ if (res.error) {
336
+ throw new Error(`WaitForMission failed for ${missionId}: ${res.error.message}`);
337
+ }
338
+ return String(res.result?.status ?? "");
339
+ },
340
+ };
341
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zeroroot-ai/zerocool",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "opencode plugin: Gibson platform integration (zerocool). Zero-config LLM, tools, findings, knowledge, delegate/missions.",
5
5
  "license": "MIT",
6
6
  "repository": {