software-defence-factory 0.15.6 → 0.16.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.
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { followActivity } from '../factory/activity-follow.mjs';
2
3
  import { harnessPreset, parseRoleDefinition, ROLE_CAPABILITIES } from '../factory/role-definition.mjs';
3
4
  import { inspectDefinition, previewDefinition, previewRollback } from '../factory/definition-store.mjs';
4
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, openSync, closeSync, rmSync, renameSync, realpathSync } from 'node:fs';
@@ -29,6 +30,7 @@ const args = process.argv.slice(2), command = args.shift() || 'help';
29
30
  const positional = [], flags = {};
30
31
  for (let i=0;i<args.length;i++) {
31
32
  if (args[i].startsWith('--')) {
33
+ if (args[i] === '--follow') { flags.follow = true; continue; }
32
34
  if (!args[i+1] || args[i+1].startsWith('--')) throw new Error(`Value required for ${args[i]}`);
33
35
  flags[args[i].slice(2)] = args[++i];
34
36
  } else positional.push(args[i]);
@@ -231,6 +233,18 @@ try {
231
233
  const snapshot=await api(state,'/api/v1/status');
232
234
  console.log(JSON.stringify(command==='automations'?snapshot.automation_control:snapshot[command],null,2));
233
235
  }
236
+ else if(command==='activity') {
237
+ const [job, attempt] = positional;
238
+ if (!/^job_[a-f0-9]+$/.test(job || '') || !/^run_[a-f0-9]+$/.test(attempt || '')) throw new Error('activity requires JOB_ID RUN_ID; inspect status for exact identities');
239
+ const controller = new AbortController(), interrupt = () => controller.abort();
240
+ process.once('SIGINT', interrupt); process.once('SIGTERM', interrupt);
241
+ try {
242
+ await followActivity({ job, attempt, after: flags.after, limit: flags.limit || '50', follow: flags.follow === true,
243
+ interval: Number(flags['poll-ms'] || 2000), signal: controller.signal,
244
+ request: (path, signal) => api(state, path, undefined, undefined, { timeoutMs: 5000, signal }),
245
+ output: page => console.log(JSON.stringify(page)), unavailable: page => console.error(JSON.stringify(page)) });
246
+ } finally { process.removeListener('SIGINT', interrupt); process.removeListener('SIGTERM', interrupt); }
247
+ }
234
248
  else if(command==='status') { const snapshot=await api(state,'/api/v1/status');delete snapshot.csrf_token;console.log(JSON.stringify(snapshot,null,2)); }
235
249
  else if(command==='doctor') {
236
250
  const config=configAt(state),dockerVersion=run('docker',['info','--format','{{.ServerVersion}}']),imageStatus=inspectImageInstallation(state,config);
@@ -383,6 +397,8 @@ Compatibility executable: software-defence-factory (same runtime and state)
383
397
  run --file task.md | --issue URL Submit software (default), or --workflow defence
384
398
  [--source-ref REF] Pin a configured-repository ref before admission
385
399
  incident --file incident.json Submit a private, read-only incident draft
400
+ activity JOB_ID RUN_ID [--after CURSOR] [--limit 1..128]
401
+ [--follow] [--poll-ms 1000..30000] Bounded JSON activity; exact-attempt reconnect
386
402
  approve JOB_ID | cancel JOB_ID Review gate / stop this attempt
387
403
  publish JOB_ID Publish/reconcile the accepted candidate as one draft PR
388
404
  abandon-delivery JOB_ID --branch-sha SHA Resolve an inspected pre-write collision; keep the remote branch
@@ -10,6 +10,7 @@ shell endpoint or a second scheduler.
10
10
  | Capability | CLI | Shared API | Dashboard | Remaining work |
11
11
  | --- | --- | --- | --- | --- |
12
12
  | Project/queue/attempt state | `status`, `inbox --source factory` JSON | `GET /api/v1/status` | Project, tasks, details/history | Stable versioned agent result/error contract |
13
+ | Bounded phase activity | `activity JOB RUN`, tail/follow and cursor reconnect | Authenticated `GET /jobs/:job/runs/:run/activity` | Current detail and History; refresh/stale state | First slice; native/installed/browser qualification and health coverage remain open (#32) |
13
14
  | Start local work | `issue start --file --title` or `--draft`, explicit `--workflow`, optional `--model` | `POST /api/v1/jobs` | Local execution only → review → Create & start locally | Persistent unstarted drafts and typed incident intake remain separate |
14
15
  | Browse repository Inbox | `inbox [--page N] [--issue-state open/closed/all]` (also `issue list --source inbox`), `issue preview --url URL` via controller provider | Authenticated `GET /api/v1/issues`, `POST /api/v1/issues/preview` using shared readers | Primary list/Board with shared status/search/workflow/model/label filters, compact Repository tools/loaded scope, item count with secondary issue/execution totals and progressive linked detail; explicit Start work for either type | Issue → execution links retained; no implicit polling |
15
16
  | Start repository work | `issue start --url URL --workflow software/defence [--brief-file operator.md]` | `POST /api/v1/issues/start` | Issue context → explicit Start work with operator brief | Rechecks current content and active admission |
@@ -177,3 +178,79 @@ observations of delivered-commit checks, not proof of required-check completion,
177
178
  branch protection, mergeability, deployment or model quality. Protected Verify,
178
179
  independent Review and explicit acceptance remain unchanged. #37 remains open
179
180
  for the other parity gaps above; no role/profile or workflow redesign is included.
181
+
182
+ ## Bounded execution activity (0.16.0, #32 first slice)
183
+
184
+ `factory activity JOB_ID RUN_ID [--after CURSOR] [--limit 1..128] [--follow]
185
+ [--poll-ms 1000..30000] --state PATH` reads the same authenticated
186
+ `GET /api/v1/jobs/:job/runs/:run/activity?after=CURSOR&limit=N` contract as the
187
+ issue detail. Discover identities with `status`. Without `after`, the default is
188
+ the last 50 events; with it, return up to the limit after that cursor in ascending
189
+ order. CLI output is one JSON page per line. Follow polls sequentially (default
190
+ 2 seconds, 5-second request timeout), preserves the cursor on transport/server
191
+ failure, reports unavailability on stderr, reconnects to that exact attempt and
192
+ stops after a terminal attempt's retained pages are drained. SIGINT/SIGTERM abort
193
+ both a request and the polling delay. Invalid requests/identity/cursors are errors,
194
+ not indefinite reconnects. Retry/revision creates another run: select it explicitly.
195
+
196
+ Result activity selects the running attempt, otherwise the latest attempt with a
197
+ recorded start, including failed, cancelled or interrupted execution. An unstarted
198
+ Handoff approval or queued retry does not hide the previous execution. The card
199
+ labels its phase and state; Observation details retains the full attempt identity.
200
+ Initial work without a started attempt says so without inventing observations.
201
+ History activity remains bound to each historical attempt.
202
+
203
+ The version-1 response identifies job, attempt and phase. `next_cursor` is the
204
+ last delivered event; `latest_cursor`, `oldest_cursor`, `has_more`, `truncated`
205
+ and `dropped` make paging, tail omission and ring rollover explicit. Cursors are
206
+ nonnegative safe integers scoped to the exact job/run URL; never carry one to a
207
+ different attempt. Ahead-of-history cursors return 409; malformed cursor/limit
208
+ returns 400; an unknown job or foreign run returns 404. Reads require the existing
209
+ operator token or dashboard session, with unchanged Host/Origin protections.
210
+ `status` is available, unavailable (including pre-feature history) or error
211
+ (invalid/inaccessible storage). No raw logs are consulted or backfilled.
212
+
213
+ `attempt_state`/`terminal` come from the queue; `completion_observed` only records
214
+ an executor end event. Interrupted/cancelled attempts may lack the end and final
215
+ unflushed observations. `phase_started_at` is the queue start, `last_received_at`
216
+ is the latest stdout chunk received by the existing executor, and `saved_at` is
217
+ the record checkpoint time. The most recent event carries its own receive-time
218
+ `at`. `process_observation`/`process_observed_at` retain the last Docker **client**
219
+ start/exit even after ring rollover; neither attests current process/container
220
+ health. `container_health` is explicitly unknown. API `refreshed_at` and the UI's
221
+ last successful read are reader facts, never worker heartbeats. Silence has no
222
+ failure threshold. A failed UI refresh retains a stale-labelled view; refresh
223
+ starts a new tail read. History reads remain tied to their original attempts.
224
+
225
+ The stdout owner records phase start/end and client start/exit. Codex
226
+ `item.completed` maps agent messages and command/file/search/MCP tool categories;
227
+ Pi maps assistant `message_end` and `tool_execution_end`. Tool completion does not
228
+ mean successful work. Reasoning, deltas, final aggregate message replay and nested
229
+ events do not become activity. Unknown/custom/deterministic harnesses report
230
+ unsupported event categories and retain only executor observations plus stdout
231
+ receive times. Mapped tool categories are read, edit, command, search and other.
232
+ Only fixed enums, timestamps, identities and bounded counters are returned.
233
+
234
+ All input text is excluded: prompts, messages, reasoning, commands, arguments,
235
+ results, paths, raw errors, arbitrary names and provider/credential data are never
236
+ copied to activity. This is an allowlist, not heuristic string redaction. Native
237
+ JSONL lines are capped at 64 KiB with strict UTF-8; malformed, oversized or trailing
238
+ incomplete lines increment `rejected`, then parsing resumes at the next newline.
239
+ Activity does not change canonical completion usage parsers or cost provenance.
240
+
241
+ The host executor owns `jobs/JOB/RUN/activity.json`, outside worker mounts. A
242
+ 128-event ring and one fixed temporary file bound storage; at most one async
243
+ atomic checkpoint is in flight, throttled to once per second during execution,
244
+ with a final flush. Input buffers are fixed-size; readers cannot create queues,
245
+ trigger writes or backpressure stdout. Readback caps bytes at 64 KiB and revalidates
246
+ identity, schema, ordering, categories, timestamps and private regular-file paths.
247
+ No activity is included in the main status payload. Checkpoint write failures do
248
+ not abort the worker; `write_errors` reports them if storage recovers. If storage
249
+ never recovers, missing/stale checkpoints cannot reveal the lost interval. A killed
250
+ executor may lose the final uncheckpointed interval. Existing private logs and
251
+ reports retain their separate bounded artifact rules; previews are labelled
252
+ snapshots with explicit refresh. Observations grant no acceptance/action authority.
253
+
254
+ This first slice leaves #32 open for independent installed/native Z13 and visual
255
+ browser qualification, richer genuinely observed health, and broader provider
256
+ coverage. See the actual evidence and limits in [proof](proof.md).
package/docs/npm.md CHANGED
@@ -116,8 +116,8 @@ installation or retained attempt needs them.
116
116
 
117
117
  ## Protected evidence compatibility
118
118
 
119
- Factory 0.15.6 recognizes version-1 execution profiles emitted by native
120
- **0.8.0, 0.9.0, 0.9.1, 0.10.0, 0.11.0, 0.11.1, 0.11.2, 0.12.0, 0.13.0, 0.13.1, 0.14.0, 0.15.0, 0.15.1, 0.15.2, 0.15.3, 0.15.4, 0.15.5 and 0.15.6**. This is an exact allowlist in
119
+ Factory 0.16.0 recognizes version-1 execution profiles emitted by native
120
+ **0.8.0, 0.9.0, 0.9.1, 0.10.0, 0.11.0, 0.11.1, 0.11.2, 0.12.0, 0.13.0, 0.13.1, 0.14.0, 0.15.0, 0.15.1, 0.15.2, 0.15.3, 0.15.4, 0.15.5, 0.15.6 and 0.16.0**. This is an exact allowlist in
121
121
  `factory/execution-profile.mjs`, independent of the installed package version;
122
122
  it is not a semver range or an automatic promise for later releases. Unknown
123
123
  runtime strings, unknown profile formats and incomplete legacy acceptance
@@ -186,7 +186,7 @@ Version 0.14.0 preserves the v1 writer only for unchanged inherited installation
186
186
  policy bytes, candidate/check/review/acceptance bindings, mounts and credential
187
187
  rules stay compatible. Adopted role overrides use **v2 from 0.14.0, 0.15.0, 0.15.1 and 0.15.2**, recording
188
188
  role/provider/effort and an exact selection digest. Later audited v2 writers are
189
- **0.15.3, 0.15.4, 0.15.5 and 0.15.6**. The executor verifies the
189
+ **0.15.3, 0.15.4, 0.15.5, 0.15.6 and 0.16.0**. The executor verifies the
190
190
  frozen common configuration before phase selection; continuation and both delivery
191
191
  validation paths require matching protected v2 evidence and private frozen config.
192
192
  V1 cannot attest an override. Mixed roles share one policy across deterministic
@@ -275,7 +275,7 @@ The 0.15.0 compatibility audit preserves unchanged v1/v2 policy construction,
275
275
  protected checks, candidate reconstruction, read-only review and acceptance guards.
276
276
  Historical 0.14.0 v2 validation compares its actual writer version rather than
277
277
  relabeling it as the installed version. Local registry selection is a new execution
278
- contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0, 0.15.1, 0.15.2, 0.15.3, 0.15.4, 0.15.5 or 0.15.6** and its
278
+ contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0, 0.15.1, 0.15.2, 0.15.3, 0.15.4, 0.15.5, 0.15.6 or 0.16.0** and its
279
279
  exact protected frozen configuration. v1/v2 cannot attest local binding fields,
280
280
  providers or a common policy containing a selected local role. A selected endpoint,
281
281
  model, limit or compatibility change invalidates the current policy; explicit
@@ -322,3 +322,20 @@ version even for a documentation-only release, so changing package metadata
322
322
  alone is insufficient. Retained evidence keeps its original version and bytes;
323
323
  unknown writers/schemas and altered selections remain blocked. This audit does
324
324
  not qualify local model productivity or upgrade failed attempts.
325
+
326
+
327
+ The 0.16.0 audit extends only the exact v1/v2/v3 writer allowlists. Compared
328
+ with admitted main 16a47cd (0.15.6), activity is a new host-owned, read-only
329
+ observation contract. Execution profile/evidence writers and schemas, effective
330
+ policy construction, role/local selection digests, candidate reconstruction,
331
+ checks/review/acceptance and delivery target guards are unchanged. Activity is
332
+ never an input to any protected-evidence reader. Regression coverage exercises
333
+ current emission and retained 0.15.6 inherited/cloud/hybrid/local profiles, all
334
+ phases, changed policy/selection/image/schema rejection and unchanged retained
335
+ bytes. Controlled delivery fixtures exercise exact known v1 writers through
336
+ capability derivation and publication validation, including 0.16.0; future
337
+ 0.16.1/unknown writers remain rejected. This does not qualify an old failed
338
+ attempt or a live installation. No image, mounted kit/skill or Pi launcher code
339
+ changed, so this capability does not require a new worker image. Qualification
340
+ must use the operator's actually selected image; no image adoption is performed
341
+ by this source change.
@@ -0,0 +1,23 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ import { activityQuery } from './activity.mjs';
3
+
4
+ // One request at a time; reconnect always retains the exact attempt and cursor.
5
+ export async function followActivity({ request, job, attempt, after = null, limit = '50', follow = false,
6
+ interval = 2000, signal, output, unavailable }) {
7
+ let { cursor } = activityQuery(after, limit);
8
+ if (!Number.isSafeInteger(interval) || interval < 1000 || interval > 30000) throw new Error('Activity poll interval must be 1000–30000 ms');
9
+ do {
10
+ if (signal?.aborted) return;
11
+ try {
12
+ const page = await request(`/api/v1/jobs/${job}/runs/${attempt}/activity?limit=${limit}${cursor === null ? '' : `&after=${cursor}`}`, signal);
13
+ if (signal?.aborted) return;
14
+ output(page); cursor = page.next_cursor;
15
+ if (!follow || (page.terminal && !page.has_more)) return;
16
+ } catch (error) {
17
+ if (signal?.aborted) return;
18
+ if (!follow || /Controller 4\d\d:/.test(error.message)) throw error;
19
+ unavailable({ status: 'unavailable', job, attempt, next_cursor: cursor, refreshed_at: null });
20
+ }
21
+ try { await delay(interval, undefined, { signal }); } catch { if (signal?.aborted) return; }
22
+ } while (follow);
23
+ }
@@ -0,0 +1,174 @@
1
+ // Private execution observations, never evidence, usage or action authority.
2
+ import { open, rename, rm } from 'node:fs/promises';
3
+ import { constants, fstatSync, lstatSync, openSync, readSync, closeSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+
6
+ export const ACTIVITY_CAP = 128;
7
+ export const ACTIVITY_BYTES = 64 * 1024;
8
+ export const ACTIVITY_LINE_BYTES = 64 * 1024;
9
+ export const ACTIVITY_KINDS = Object.freeze(['phase_started', 'process_started', 'message_completed',
10
+ 'tool_completed', 'process_exited', 'phase_completed', 'phase_error']);
11
+ export const TOOL_CATEGORIES = Object.freeze(['read', 'edit', 'command', 'search', 'other']);
12
+ const phases = ['build', 'verify', 'review', 'handoff', 'defence'];
13
+ const timestamp = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value));
14
+ const integer = value => Number.isSafeInteger(value) && value >= 0;
15
+ const keys = (value, allowed) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => allowed.includes(key));
16
+ const increment = value => Math.min(Number.MAX_SAFE_INTEGER, value + 1);
17
+ const codexTools = { command_execution: 'command', file_change: 'edit', web_search: 'search', mcp_tool_call: 'other' };
18
+ const piTools = { read: 'read', write: 'edit', edit: 'edit', bash: 'command', grep: 'search', find: 'search', ls: 'read' };
19
+
20
+ // Independent from usage parsing: strict UTF-8 JSONL, no tail fragments, no raw
21
+ // input retained after each line, and a fixed buffer even for newline-free output.
22
+ export class ActivityParser {
23
+ constructor(harness, emit, received = () => {}, lost = () => {}) {
24
+ this.harness = harness; this.emit = emit; this.received = received; this.lost = lost;
25
+ this.buffer = Buffer.alloc(ACTIVITY_LINE_BYTES); this.length = 0; this.overflow = false;
26
+ this.decoder = new TextDecoder('utf-8', { fatal: true });
27
+ }
28
+ write(chunk) {
29
+ if (!chunk.length) return;
30
+ this.received();
31
+ if (!['codex', 'pi'].includes(this.harness)) return;
32
+ for (const byte of chunk) {
33
+ if (byte === 10) { this.complete(); continue; }
34
+ if (this.length < this.buffer.length) this.buffer[this.length++] = byte;
35
+ else this.overflow = true;
36
+ }
37
+ }
38
+ complete() {
39
+ try {
40
+ if (this.overflow) throw new Error();
41
+ if (!this.length) return;
42
+ const event = JSON.parse(this.decoder.decode(this.buffer.subarray(0, this.length)));
43
+ if (!event || typeof event !== 'object' || Array.isArray(event)) throw new Error();
44
+ if (this.harness === 'codex' && event.type === 'item.completed') {
45
+ const type = event.item?.type;
46
+ if (type === 'agent_message') this.emit('message_completed');
47
+ else if (Object.hasOwn(codexTools, type)) this.emit('tool_completed', codexTools[type]);
48
+ }
49
+ if (this.harness === 'pi') {
50
+ if (event.type === 'message_end' && event.message?.role === 'assistant') this.emit('message_completed');
51
+ if (event.type === 'tool_execution_end' && typeof event.toolName === 'string')
52
+ this.emit('tool_completed', Object.hasOwn(piTools, event.toolName) ? piTools[event.toolName] : 'other');
53
+ }
54
+ } catch { this.lost(); }
55
+ finally { this.buffer.fill(0, 0, this.length); this.length = 0; this.overflow = false; }
56
+ }
57
+ finish() {
58
+ if (this.length || this.overflow) this.lost();
59
+ this.buffer.fill(0); this.length = 0; this.overflow = false;
60
+ }
61
+ }
62
+
63
+ export class ActivityWriter {
64
+ constructor(directory, { job, attempt, phase, harness }, { interval = 1000 } = {}) {
65
+ this.path = join(directory, 'activity.json'); this.interval = interval;
66
+ this.record = { version: 1, job, attempt, phase, support: ['codex', 'pi'].includes(harness) ? harness : 'unsupported',
67
+ cursor: 0, dropped: 0, rejected: 0, write_errors: 0, last_received_at: null, process_observation: null, process_observed_at: null, saved_at: null, events: [] };
68
+ this.dirty = false; this.pending = null; this.timer = null; this.closed = false;
69
+ this.parser = new ActivityParser(harness, (kind, tool) => this.emit(kind, tool),
70
+ () => { this.record.last_received_at = new Date().toISOString(); this.schedule(); },
71
+ () => { this.record.rejected = increment(this.record.rejected); this.schedule(); });
72
+ this.emit('phase_started');
73
+ }
74
+ emit(kind, tool) {
75
+ if (!ACTIVITY_KINDS.includes(kind) || (tool !== undefined && !TOOL_CATEGORIES.includes(tool))) throw new Error('Invalid activity category');
76
+ if (this.record.cursor === Number.MAX_SAFE_INTEGER) { this.record.rejected = increment(this.record.rejected); this.schedule(); return; }
77
+ if (['process_started', 'process_exited'].includes(kind)) {
78
+ this.record.process_observation = kind; this.record.process_observed_at = new Date().toISOString();
79
+ }
80
+ this.record.events.push({ cursor: ++this.record.cursor, at: new Date().toISOString(), kind, ...(tool ? { tool } : {}) });
81
+ if (this.record.events.length > ACTIVITY_CAP) { this.record.events.shift(); this.record.dropped++; }
82
+ this.schedule();
83
+ }
84
+ schedule() {
85
+ this.dirty = true;
86
+ if (!this.closed && !this.timer) this.timer = setTimeout(() => { this.timer = null; void this.flush(); }, this.interval);
87
+ }
88
+ async flush() {
89
+ if (this.pending) { await this.pending; return; }
90
+ if (!this.dirty) return;
91
+ this.dirty = false;
92
+ const snapshot = JSON.stringify({ ...this.record, saved_at: new Date().toISOString() });
93
+ // At most one write in flight; clients never trigger writes. A fixed temp
94
+ // name and atomic replacement bound disk usage to two capped records.
95
+ this.pending = (async () => {
96
+ let handle;
97
+ try {
98
+ handle = await open(`${this.path}.tmp`, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
99
+ await handle.chmod(0o600); await handle.writeFile(snapshot); await handle.sync(); await handle.close(); handle = null;
100
+ await rename(`${this.path}.tmp`, this.path);
101
+ } catch {
102
+ this.record.write_errors = increment(this.record.write_errors); this.dirty = true;
103
+ await handle?.close().catch(() => {}); await rm(`${this.path}.tmp`, { force: true }).catch(() => {});
104
+ }
105
+ })();
106
+ await this.pending; this.pending = null;
107
+ if (this.dirty && !this.closed) this.schedule();
108
+ }
109
+ async finish(success) {
110
+ if (this.closed) return;
111
+ this.closed = true; clearTimeout(this.timer); this.timer = null;
112
+ this.parser.finish(); this.emit(success ? 'phase_completed' : 'phase_error');
113
+ await this.pending; await this.flush();
114
+ }
115
+ }
116
+
117
+ export function activityQuery(after, limit = '50') {
118
+ if (after !== null && after !== undefined && !/^(0|[1-9]\d{0,15})$/.test(String(after))) throw Object.assign(new Error('Invalid activity cursor'), { status: 400 });
119
+ const cursor = after == null ? null : Number(after), count = Number(limit);
120
+ if ((cursor !== null && !integer(cursor)) || !/^[1-9]\d{0,2}$/.test(String(limit)) || !integer(count) || count > ACTIVITY_CAP)
121
+ throw Object.assign(new Error('Invalid activity cursor or limit'), { status: 400 });
122
+ return { cursor, limit: count };
123
+ }
124
+
125
+ export function readActivity(state, job, attempt, query = activityQuery(null)) {
126
+ if (!/^job_[a-f0-9]+$/.test(job.id) || !/^run_[a-f0-9]+$/.test(attempt.id) || !phases.includes(attempt.command))
127
+ throw Object.assign(new Error('Activity identity unavailable'), { status: 400 });
128
+ const terminal = ['succeeded', 'failed', 'cancelled', 'interrupted'].includes(attempt.state);
129
+ const result = { version: 1, job: job.id, attempt: attempt.id, phase: attempt.command,
130
+ attempt_state: attempt.state, terminal, container_health: 'unknown', refreshed_at: new Date().toISOString(),
131
+ status: 'unavailable', support: 'unknown', events: [], next_cursor: query.cursor ?? 0,
132
+ latest_cursor: null, oldest_cursor: null, truncated: false, has_more: false,
133
+ last_received_at: null, process_observation: null, process_observed_at: null, phase_started_at: timestamp(attempt.started_at) ? attempt.started_at : null,
134
+ saved_at: null, dropped: 0, rejected: 0, write_errors: 0, completion_observed: false };
135
+ let record, fd;
136
+ try {
137
+ const folder = join(state, 'jobs', job.id, attempt.id);
138
+ for (const dir of [join(state, 'jobs'), join(state, 'jobs', job.id), folder]) {
139
+ const stat = lstatSync(dir);
140
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077)) throw new Error();
141
+ }
142
+ fd = openSync(join(folder, 'activity.json'), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
143
+ const stat = fstatSync(fd);
144
+ if (!stat.isFile() || (stat.mode & 0o077) || stat.size > ACTIVITY_BYTES) throw new Error();
145
+ const buffer = Buffer.alloc(ACTIVITY_BYTES + 1), size = readSync(fd, buffer, 0, buffer.length, 0);
146
+ if (size > ACTIVITY_BYTES) throw new Error();
147
+ record = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(buffer.subarray(0, size)));
148
+ if (!keys(record, ['version','job','attempt','phase','support','cursor','dropped','rejected','write_errors','last_received_at','process_observation','process_observed_at','saved_at','events'])
149
+ || record.version !== 1 || record.job !== job.id || record.attempt !== attempt.id || record.phase !== attempt.command
150
+ || !['codex','pi','unsupported'].includes(record.support) || !integer(record.cursor) || !integer(record.dropped)
151
+ || !integer(record.rejected) || !integer(record.write_errors) || !timestamp(record.saved_at)
152
+ || (record.last_received_at !== null && !timestamp(record.last_received_at))
153
+ || (record.process_observation === null ? record.process_observed_at !== null
154
+ : !['process_started','process_exited'].includes(record.process_observation) || !timestamp(record.process_observed_at))
155
+ || !Array.isArray(record.events) || !record.events.length || record.events.length > ACTIVITY_CAP
156
+ || record.cursor !== record.dropped + record.events.length) throw new Error();
157
+ for (const [index, event] of record.events.entries()) {
158
+ if (!keys(event, ['cursor','at','kind','tool']) || event.cursor !== record.dropped + index + 1
159
+ || !timestamp(event.at) || !ACTIVITY_KINDS.includes(event.kind)
160
+ || (event.kind === 'tool_completed' ? !TOOL_CATEGORIES.includes(event.tool) : event.tool !== undefined)) throw new Error();
161
+ }
162
+ } catch (error) { result.status = error.code === 'ENOENT' ? 'unavailable' : 'error'; return result; }
163
+ finally { if (fd !== undefined) closeSync(fd); }
164
+ if (query.cursor !== null && query.cursor > record.cursor) throw Object.assign(new Error('Activity cursor is ahead of retained history; reconnect with the same attempt and no cursor'), { status: 409 });
165
+ const start = query.cursor ?? Math.max(record.dropped, record.cursor - query.limit);
166
+ const events = record.events.filter(event => event.cursor > start).slice(0, query.limit);
167
+ return { ...result, status: 'available', support: record.support, events,
168
+ next_cursor: events.at(-1)?.cursor ?? start, latest_cursor: record.cursor, oldest_cursor: record.dropped + 1,
169
+ truncated: start < record.dropped || (query.cursor === null && start > 0),
170
+ has_more: (events.at(-1)?.cursor ?? start) < record.cursor,
171
+ last_received_at: record.last_received_at, process_observation: record.process_observation, process_observed_at: record.process_observed_at, saved_at: record.saved_at,
172
+ dropped: record.dropped, rejected: record.rejected, write_errors: record.write_errors,
173
+ completion_observed: ['phase_completed', 'phase_error'].includes(record.events.at(-1)?.kind) };
174
+ }
@@ -33,16 +33,18 @@ import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
33
33
  // frozen selection hashes, provenance, checks and acceptance guards are unchanged.
34
34
  // 0.15.6 corrects packaged setup guidance and release metadata only; writers,
35
35
  // schemas, isolation, frozen hashes, reconstruction and acceptance are unchanged.
36
+ // 0.16.0 adds host-owned activity only; protected writers, policy/selection
37
+ // hashes, candidate reconstruction, mounts and acceptance guards are unchanged.
36
38
  // Older writers still require exact patch/tree reconstruction before publication.
37
39
  // Deliberately independent of VERSION: a release bump is not
38
40
  // evidence compatibility. Re-audit this list before every release, including
39
41
  // writer, isolation and validation changes; remove versions whose guarantees no longer
40
42
  // satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
41
- const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0', '0.13.0', '0.13.1', '0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5', '0.15.6']);
43
+ const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0', '0.11.0', '0.11.1', '0.11.2', '0.12.0', '0.13.0', '0.13.1', '0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5', '0.15.6', '0.16.0']);
42
44
 
43
45
  export function isSupportedExecutionProfile(profile) {
44
- if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5', '0.15.6'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
45
- : ['0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5', '0.15.6'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
46
+ if ([2, 3].includes(profile?.version)) return (profile.version === 3 ? ['0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5', '0.15.6', '0.16.0'].includes(profile.runtimeVersion) && Object.hasOwn(profile, 'localBinding')
47
+ : ['0.14.0', '0.15.0', '0.15.1', '0.15.2', '0.15.3', '0.15.4', '0.15.5', '0.15.6', '0.16.0'].includes(profile.runtimeVersion) && !Object.hasOwn(profile, 'localBinding') && profile.inferenceProvider !== 'factory-local')
46
48
  && profile.role === (Object.keys(ROLE_PHASES).find(role => ROLE_PHASES[role] === profile.phase) || null)
47
49
  && (profile.reasoningEffort === null || ['low', 'medium', 'high'].includes(profile.reasoningEffort))
48
50
  && /^[a-f0-9]{64}$/.test(profile.selectionHash || '');
@@ -9,6 +9,7 @@ import { ROOT, run, save, json, digest, instanceLabel, stopContainers } from './
9
9
  import { incidentFor, validateReport } from './incident.mjs';
10
10
  import { BoundedLog } from './bounded-log.mjs';
11
11
  import { CodexUsageParser, emptyUsage, usageFields } from './usage.mjs';
12
+ import { ActivityWriter } from './activity.mjs';
12
13
  import { PiUsageParser } from './pi-usage.mjs';
13
14
  import { assertRetainedSource, publicSourceAdmission, publicContinuation, restoreBuildCheckout } from './source-admission.mjs';
14
15
  import { runCandidateGit, runCandidateGitRaw } from './git-environment.mjs';
@@ -43,6 +44,8 @@ const lock = join(folder, 'active.json');
43
44
  // A killed executor leaves this fence. Recovery must establish stopped processes/containers.
44
45
  writeFileSync(lock, JSON.stringify({ pid: process.pid, pgid:Number(run('ps',['-p',String(process.pid),'-o','pgid='])), attempt, phase }), { flag: 'wx', mode: 0o600 });
45
46
  const started = Date.now();
47
+ const activity = new ActivityWriter(join(folder, attempt), { job, attempt, phase,
48
+ harness: ['build', 'review', 'defence'].includes(phase) ? execution.executor : null });
46
49
  let prompt = '';
47
50
  for await (const part of process.stdin) {
48
51
  prompt += part;
@@ -130,7 +133,9 @@ async function container(mode, input, command, options = {}) {
130
133
  console.log(JSON.stringify({ phase: mode, event: 'started', synthetic: harnessOf(config) === 'mock' }));
131
134
  code = await new Promise((ok, fail) => {
132
135
  const child = spawn('docker', args, { stdio: ['pipe','pipe','pipe'] });
136
+ child.once('spawn', () => activity.emit('process_started'));
133
137
  child.stdout.on('data', bytes => {
138
+ activity.parser.write(bytes);
134
139
  usageParser?.write(bytes);
135
140
  // Controller-owned metadata, outside the worker's /output mount. Atomic
136
141
  // checkpoints survive cancellation without consulting mixed log tails.
@@ -143,7 +148,7 @@ async function container(mode, input, command, options = {}) {
143
148
  });
144
149
  child.stderr.on('data', bytes => log.write('stderr', bytes));
145
150
  child.stdin.on('error',error => { if (error.code !== 'EPIPE') fail(error); });
146
- child.on('error',fail); child.on('close',(code,signal) => { exitSignal=signal; ok(code); }); child.stdin.end(input);
151
+ child.on('error',fail); child.on('close',(code,signal) => { exitSignal=signal; activity.emit('process_exited'); ok(code); }); child.stdin.end(input);
147
152
  });
148
153
  const parsedUsage = usageParser?.finish();
149
154
  if (parsedUsage) observedUsage = parsedUsage;
@@ -407,6 +412,7 @@ try {
407
412
  save(result,{outcome:'blocked', ...usageFields(observedUsage, execution, phase), ...(reviewVerdict ? {review_verdict:reviewVerdict} : {}), ...(webVerificationResult ? { web_verification: webVerificationResult } : {}), summary:error.message});
408
413
  process.exitCode=1;
409
414
  } finally {
415
+ await activity.finish(completed);
410
416
  const measurement = { job, attempt, phase, policyHash, execution, ...usageFields(observedUsage, execution, phase), completed, durationMs: Date.now()-started, requestedModel: execution.requestedModel, directCost: null, humanTime: null, synthetic: harnessOf(config) === 'mock' };
411
417
  save(join(folder,`measurement-${attempt}.json`),measurement);save(join(output,`measurement-${attempt}.json`),measurement);
412
418
  // If cleanup cannot be confirmed, retain the lock and require explicit recovery.
package/factory/lib.mjs CHANGED
@@ -88,14 +88,14 @@ export function configAt(state) {
88
88
  }
89
89
  return config;
90
90
  }
91
- export async function api(state, path, body, method, { timeoutMs = API_TIMEOUT_MS } = {}) {
91
+ export async function api(state, path, body, method, { timeoutMs = API_TIMEOUT_MS, signal } = {}) {
92
92
  if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_API_TIMEOUT_MS)
93
93
  throw new Error(`API timeout must be between 1 and ${MAX_API_TIMEOUT_MS} milliseconds`);
94
94
  const config = configAt(state);
95
95
  const response = await fetch(`http://127.0.0.1:${config.port}${path}`, {
96
96
  method: method || (body === undefined ? 'GET' : 'POST'),
97
97
  headers: { Authorization: `Bearer ${readFileSync(join(state, 'worker.token'), 'utf8').trim()}`, 'Content-Type': 'application/json' },
98
- body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs),
98
+ body: body === undefined ? undefined : JSON.stringify(body), signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs),
99
99
  });
100
100
  const text = await response.text();
101
101
  if (!response.ok) throw new Error(`Controller ${response.status}: ${text}`);
@@ -1,3 +1,4 @@
1
+ import { activityQuery, readActivity } from './activity.mjs';
1
2
  import { inspectDefinition, previewDefinition, previewRollback, changeDefinition } from './definition-store.mjs';
2
3
  import { associateIssue, backlogHistory, readinessMapping, workRecords } from './issue-lifecycle.mjs';
3
4
  import { harnessOf } from './lib.mjs';
@@ -130,6 +131,13 @@ export function createController(state, adapter = executors(state), integrations
130
131
  if (!authenticated) throw new QueueError('Session required', 403);
131
132
  return send(200, url.pathname.endsWith('issue-submissions') ? submissions.list() : { ...providerInfo(provider), ...(provider.supported ? await provider.context() : {}) });
132
133
  }
134
+ const activity = url.pathname.match(/^\/api\/v1\/jobs\/(job_[a-f0-9]+)\/runs\/(run_[a-f0-9]+)\/activity$/);
135
+ if (request.method === 'GET' && activity) {
136
+ if (!authenticated) throw new QueueError('Session required', 403);
137
+ const job = queue.get(activity[1]), attempt = job.runs.find(run => run.id === activity[2]);
138
+ if (!attempt) throw new QueueError('Attempt not found for this job', 404);
139
+ return send(200, readActivity(state, job, attempt, activityQuery(url.searchParams.get('after'), url.searchParams.get('limit') ?? '50')));
140
+ }
133
141
  const content = url.pathname.match(/^\/api\/v1\/artifacts\/(job_[a-f0-9]+)~(run_[a-f0-9]+)~([\w.-]+)\/content$/);
134
142
  const artifactList = url.pathname.match(/^\/api\/v1\/jobs\/(job_[a-f0-9]+)\/artifacts$/);
135
143
  if (request.method === 'GET' && (artifactList || content)) {