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.
- package/bin/software-defence-factory.mjs +16 -0
- package/docs/interfaces.md +77 -0
- package/docs/npm.md +21 -4
- package/factory/activity-follow.mjs +23 -0
- package/factory/activity.mjs +174 -0
- package/factory/execution-profile.mjs +5 -3
- package/factory/executor.mjs +7 -1
- package/factory/lib.mjs +2 -2
- package/factory/server.mjs +8 -0
- package/factory/ui/assets/{index-DbTPjlnO.js → index-DKxMj9rX.js} +9 -9
- package/factory/ui/assets/{index-BDZ8MNoD.css → index-cssmuHDd.css} +1 -1
- package/factory/ui/index.html +2 -2
- package/package.json +1 -1
|
@@ -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
|
package/docs/interfaces.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 || '');
|
package/factory/executor.mjs
CHANGED
|
@@ -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}`);
|
package/factory/server.mjs
CHANGED
|
@@ -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)) {
|