software-defence-factory 0.15.5 → 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 +54 -7
- package/docs/setup.md +74 -52
- package/factory/activity-follow.mjs +23 -0
- package/factory/activity.mjs +174 -0
- package/factory/execution-profile.mjs +9 -5
- 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
|
|
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
|
|
@@ -185,7 +185,8 @@ Verify evidence and cannot authorize acceptance or publication.
|
|
|
185
185
|
Version 0.14.0 preserves the v1 writer only for unchanged inherited installations:
|
|
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
|
-
role/provider/effort and an exact selection digest.
|
|
188
|
+
role/provider/effort and an exact selection digest. Later audited v2 writers are
|
|
189
|
+
**0.15.3, 0.15.4, 0.15.5, 0.15.6 and 0.16.0**. The executor verifies the
|
|
189
190
|
frozen common configuration before phase selection; continuation and both delivery
|
|
190
191
|
validation paths require matching protected v2 evidence and private frozen config.
|
|
191
192
|
V1 cannot attest an override. Mixed roles share one policy across deterministic
|
|
@@ -220,9 +221,29 @@ that the release is not yet downloadable, retain the prior runtime and retry the
|
|
|
220
221
|
update later. This does not establish whether a particular delay came from a
|
|
221
222
|
local cache or registry propagation.
|
|
222
223
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
224
|
+
Before shipping any version, including a documentation-only patch:
|
|
225
|
+
|
|
226
|
+
1. Audit the diff from the previous release across profile/evidence writers,
|
|
227
|
+
schemas, isolation, frozen selection and policy hashes, checks, candidate
|
|
228
|
+
reconstruction, review and acceptance. Add only the exact audited writer to
|
|
229
|
+
the applicable v1/v2/v3 lists in `factory/execution-profile.mjs`. Never derive
|
|
230
|
+
compatibility from package `VERSION` or a semver range; an incompatible writer
|
|
231
|
+
blocks release until its evidence contract is resolved.
|
|
232
|
+
2. Align `package.json`, the lockfile root and `packages[""]` versions (for
|
|
233
|
+
example with `npm version patch --no-git-tag-version`). Run
|
|
234
|
+
`npm ci --ignore-scripts`, `npm run build:dashboard` and `npm run check` on the
|
|
235
|
+
final candidate, then inspect the packed artifact. Exercise the native current
|
|
236
|
+
writer, retained evidence, continuation and protected-evidence rejection
|
|
237
|
+
checks. Keep unknown-writer fixtures clearly unaudited (for example `99.0.0`),
|
|
238
|
+
and preserve historical bytes and every current-policy guard.
|
|
239
|
+
3. Obtain independent review of that exact candidate and qualify the installed
|
|
240
|
+
CLI/API with retained evidence. Deliver through the protected PR/main CI flow,
|
|
241
|
+
verify the public npm artifact against the reviewed package, then adopt only
|
|
242
|
+
while idle and reconciled with private configuration, history and rollback
|
|
243
|
+
data preserved. Source tests alone do not establish these delivery gates.
|
|
244
|
+
|
|
245
|
+
A code push without a version bump is tested but does not overwrite a published
|
|
246
|
+
package.
|
|
226
247
|
|
|
227
248
|
The current trusted-publisher identity is npm package `software-defence-factory`,
|
|
228
249
|
GitHub owner `arcitai`, repository `software-and-defence-factory`,
|
|
@@ -254,7 +275,7 @@ The 0.15.0 compatibility audit preserves unchanged v1/v2 policy construction,
|
|
|
254
275
|
protected checks, candidate reconstruction, read-only review and acceptance guards.
|
|
255
276
|
Historical 0.14.0 v2 validation compares its actual writer version rather than
|
|
256
277
|
relabeling it as the installed version. Local registry selection is a new execution
|
|
257
|
-
contract: every phase of a local/hybrid attempt requires **v3 from 0.15.0, 0.15.1
|
|
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
|
|
258
279
|
exact protected frozen configuration. v1/v2 cannot attest local binding fields,
|
|
259
280
|
providers or a common policy containing a selected local role. A selected endpoint,
|
|
260
281
|
model, limit or compatibility change invalidates the current policy; explicit
|
|
@@ -292,3 +313,29 @@ remain unchanged from 0.15.4. Profile tests exercise all phases and inherited,
|
|
|
292
313
|
cloud, hybrid and local definitions, reject changed selections and future writers,
|
|
293
314
|
and verify immutable readback. This does not upgrade or qualify historical local
|
|
294
315
|
attempts; installed image adoption and actual inference remain separate gates.
|
|
316
|
+
|
|
317
|
+
The 0.15.6 audit adds only that exact writer to the explicit v1/v2/v3 lists for
|
|
318
|
+
the packaged setup-guide correction. Compared with 0.15.5, profile/evidence
|
|
319
|
+
writers and schemas, isolation, frozen selection/policy hashes, checks,
|
|
320
|
+
reconstruction, review and acceptance are unchanged. The runtime emits its new
|
|
321
|
+
version even for a documentation-only release, so changing package metadata
|
|
322
|
+
alone is insufficient. Retained evidence keeps its original version and bytes;
|
|
323
|
+
unknown writers/schemas and altered selections remain blocked. This audit does
|
|
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.
|
package/docs/setup.md
CHANGED
|
@@ -393,43 +393,67 @@ local requests still use `issue start --file` or `--draft` without `--brief-file
|
|
|
393
393
|
|
|
394
394
|
## Optional keyless local or hybrid inference
|
|
395
395
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
396
|
+
Local profiles are opt-in. Follow the [local binding contract](definition.md#opt-in-local-bindings-0150-69)
|
|
397
|
+
for supported fields and commands; configuration is not qualification and starts
|
|
398
|
+
no jobs, downloads or inference services.
|
|
399
|
+
|
|
400
|
+
1. **Keep the working configuration.** Export portable roles with
|
|
401
|
+
`factory definition export --state PATH > factory-roles.json`. Export omits
|
|
402
|
+
private bindings: also retain the selected immutable image ID and a complete
|
|
403
|
+
stopped private-state backup using [recovery](recovery.md).
|
|
404
|
+
2. **Qualify the inference path.** From a disposable container using the selected
|
|
405
|
+
job image and network, verify the exact endpoint/model, tool calls/results and
|
|
406
|
+
report compatibility. Record server/harness versions, model digest and
|
|
407
|
+
quantization, GPU/backend and actual offload, available/peak memory and actual
|
|
408
|
+
context allocation, including request/model overrides. Host loopback is not
|
|
409
|
+
container loopback. Keep inference access separate from forge/controller
|
|
410
|
+
credentials; missing models/providers must fail without silent cloud fallback.
|
|
411
|
+
3. **Preview and explicitly apply roles.** Use the linked binding recipe to select
|
|
412
|
+
local Pi Implement and an independently chosen local or cloud Review. Preview
|
|
413
|
+
with `factory definition diff`, then use `factory definition apply` with the
|
|
414
|
+
same `--state`, `--file`, `--bindings-file` and the preview's `--expected-revision`.
|
|
415
|
+
Apply requires the
|
|
416
|
+
running controller to be idle and reconciled. Agents/Definition uses the same
|
|
417
|
+
diff/apply contract. Inspect the effective selections; each attempt freezes
|
|
418
|
+
them with its resource, timeout and skill policy. CLI upgrades and worker-image
|
|
419
|
+
selection are separate; use the [image procedure below](#local-context-budget-and-worker-image-0152-100)
|
|
420
|
+
if the selected image needs changing.
|
|
421
|
+
4. **Qualify bounded representative work before a real repository issue.** Use
|
|
422
|
+
pinned fixture bases, observable decisive tests independent of Implement, and
|
|
423
|
+
separate Review contexts. For a comparison, use two small software fixtures,
|
|
424
|
+
comparable prompts/contexts/trials, a measured local baseline and one selected
|
|
425
|
+
local/hybrid alternative. Build returns to Factory's checks, independent Review
|
|
426
|
+
and operator handoff; it does not launch another review pipeline. Set explicit
|
|
427
|
+
deadlines/resource budgets and exercise tool use, context recovery and reports.
|
|
428
|
+
5. **Inspect history and usage.** Use `factory status --state PATH` and the
|
|
429
|
+
dashboard's attempt history/Analytics. Retain raw failures, diagnostics,
|
|
430
|
+
unfinished work and repairs under [recovery](recovery.md). Record wall time,
|
|
431
|
+
observable prefill/decode throughput, memory/offload, token observations,
|
|
432
|
+
tool/report failures, checks/review outcomes and human time, including cloud
|
|
433
|
+
Review usage in hybrid totals. Unknown measurements stay unknown; see
|
|
434
|
+
[qualification evidence and limits](proof.md) before generalizing a result.
|
|
435
|
+
6. **Roll back explicitly when needed.** With the running controller idle,
|
|
436
|
+
`factory definition rollback --state PATH` previews the previous roles/bindings;
|
|
437
|
+
add `--expected-revision` from that preview to apply it. Image rollback is a
|
|
438
|
+
separate stopped-installation operation described below. Preserve attempts and
|
|
439
|
+
backups; rollback does not rewrite failed history or retry work.
|
|
440
|
+
|
|
441
|
+
Fresh Pi completed-message observations have **partial** coverage: input includes
|
|
442
|
+
cache reads/writes, with cached input, cache-write input and output shown separately;
|
|
443
|
+
cache subsets are not added again. Absent or old usage remains unknown, with no Pi
|
|
444
|
+
historical backfill. Summary and unreported error/retry usage remains unreported,
|
|
445
|
+
even when the attempt succeeds. See [Pi usage semantics](usage.md#pi-stdout-measurements-0154).
|
|
446
|
+
These observations establish neither total cost nor speed or quality. Local
|
|
447
|
+
inference avoids provider token billing, not electricity, hardware or operator cost.
|
|
448
|
+
|
|
449
|
+
Set context/output limits against actual allocation with headroom as described
|
|
450
|
+
below. Before recommending larger windows, test a bounded 64k/128k allocation
|
|
451
|
+
where supported, recording KV-cache/Flash Attention settings and memory separately from
|
|
452
|
+
long-context task quality. An advertised window or successful short prompt is
|
|
453
|
+
insufficient. Authenticated local endpoints, sampling overrides and custom thinking
|
|
454
|
+
maps are unsupported; supported reasoning-effort requests still need endpoint
|
|
455
|
+
qualification and do not prove a server honored them. No model/provider is chosen
|
|
456
|
+
automatically. Broader local/hybrid qualification remains open in #69.
|
|
433
457
|
|
|
434
458
|
|
|
435
459
|
## Local Pi project resources (0.15.3, #103)
|
|
@@ -455,13 +479,12 @@ extensions/packages and automatically discovered repository skills are not a
|
|
|
455
479
|
supported configuration path. Explicit task-driven file reads are still possible
|
|
456
480
|
within the sandbox and grant no additional authority.
|
|
457
481
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
attempt after qualification. Do not modify private installations from a job.
|
|
482
|
+
Qualify native local Build and separate read-only Review with the exact selected
|
|
483
|
+
image and model. `npm run qualify:pi` exercises actual Pi with **synthetic HTTP
|
|
484
|
+
inference**, not model judgment. See [trust-store proof and limits](proof.md#pi-project-trust-startup-0153-103)
|
|
485
|
+
and the later qualification records in [proof](proof.md). Preserve failed attempts
|
|
486
|
+
and use [recovery](recovery.md) before retrying; jobs must not modify private
|
|
487
|
+
installations.
|
|
465
488
|
|
|
466
489
|
## Local context budget and worker image (0.15.2, #100)
|
|
467
490
|
|
|
@@ -509,9 +532,8 @@ guessed or silently rewritten. Do not add project Pi settings to bypass
|
|
|
509
532
|
the admitted binding or rely on custom tuning that has not been qualified.
|
|
510
533
|
See [pinned compaction behavior](https://github.com/earendil-works/pi/blob/v0.87.1/packages/coding-agent/docs/compaction.md).
|
|
511
534
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
signal counts the system/task/tool envelope once, call arguments, non-tool text
|
|
535
|
+
The local adapter also checks history pressure at Pi's completed-tool-turn
|
|
536
|
+
boundary. This conservative byte signal counts the system/task/tool envelope once, call arguments, non-tool text
|
|
515
537
|
and capped tool excerpts, separately from provider-reported usage. At 65% of the
|
|
516
538
|
input budget it considers Pi's supported summary generator and append-only
|
|
517
539
|
compaction entry. The newest complete tool group is retained; up to three older
|
|
@@ -541,7 +563,7 @@ output on pinned Pi). `factory_context_recovery` JSONL events record only
|
|
|
541
563
|
pressure, envelope/floor sizes, group counts and recovery progress, without
|
|
542
564
|
prompts/content. Pi token accounting stays partial; this summary path is not
|
|
543
565
|
added to ordinary assistant usage totals. See
|
|
544
|
-
[
|
|
566
|
+
[context-recovery measurements, retained failures and qualification limits](proof.md#short-local-pi-history-recovery-0155-candidate-105).
|
|
545
567
|
|
|
546
568
|
Before adoption, stop and reconcile all work, retain the old immutable image ID
|
|
547
569
|
and a stopped private-state backup. Build/qualify the exact candidate image, then
|
|
@@ -556,7 +578,7 @@ readable and cloud profiles keep their existing wrapper. Roll back with the reta
|
|
|
556
578
|
path and restore the previously working role definition through revision-guarded
|
|
557
579
|
rollback. Do not change frozen attempts or relabel old failed results.
|
|
558
580
|
|
|
559
|
-
|
|
581
|
+
A bounded actual-inference fixture must include enough tool results to trigger
|
|
560
582
|
summarization, resumed useful tool work, the required report, checks and independent
|
|
561
583
|
Review. Record the exact image/model digest, harness/server versions, allocated
|
|
562
584
|
window, output/reasoning request, tool growth, compaction ordering, elapsed time,
|
|
@@ -564,6 +586,6 @@ quality outcome and peak memory; unknown measurements remain null. Set an explic
|
|
|
564
586
|
job deadline/resource budget and preserve any unfinished checkout and diagnostic
|
|
565
587
|
artifacts. A short successful prompt is insufficient. A larger actually allocated
|
|
566
588
|
window is a later measured comparison only if this evidence warrants it; do not
|
|
567
|
-
switch model or cloud provider automatically.
|
|
568
|
-
|
|
569
|
-
|
|
589
|
+
switch model or cloud provider automatically. An operator-selected fresh cloud
|
|
590
|
+
retry and unfinished-checkpoint recovery remain governed by [recovery](recovery.md); neither converts failed local work into local
|
|
591
|
+
delivery.
|
|
@@ -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
|
+
}
|