software-defence-factory 0.9.1 → 0.10.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 +10 -3
- package/docs/interfaces.md +2 -2
- package/docs/npm.md +21 -8
- package/docs/recovery.md +67 -1
- package/factory/delivery.mjs +1 -32
- package/factory/execution-evidence.mjs +96 -1
- package/factory/execution-profile.mjs +2 -2
- package/factory/executor.mjs +10 -8
- package/factory/processes.mjs +15 -6
- package/factory/queue.mjs +42 -2
- package/factory/server.mjs +2 -1
- package/factory/source-admission.mjs +93 -1
- package/factory/ui/assets/index-C9gpwCUM.js +13 -0
- package/factory/ui/assets/{index-CLrogux1.css → index-CasRG9Ge.css} +1 -1
- package/factory/ui/index.html +2 -2
- package/package.json +1 -1
- package/factory/ui/assets/index-Cjk_XtEU.js +0 -13
|
@@ -113,15 +113,22 @@ async function jobAction(action) {
|
|
|
113
113
|
const current=job.runs.at(-1);
|
|
114
114
|
if(action==='approve'&&job.state!=='awaiting_approval')throw new Error('Job is not awaiting approval');
|
|
115
115
|
if(action==='retry'&&!['interrupted','failed','blocked','cancelled'].includes(job.state))throw new Error('Only a stopped attempt can be retried');
|
|
116
|
-
let feedback;
|
|
116
|
+
let feedback, revision = {};
|
|
117
117
|
if(action==='request_changes') {
|
|
118
118
|
if(!flags.file)throw new Error('revise requires --file feedback.md');
|
|
119
|
+
if (flags.from !== undefined && !['admitted-source', 'reviewed-candidate'].includes(flags.from)) throw new Error('revise --from must be admitted-source or reviewed-candidate');
|
|
120
|
+
if (flags.from !== undefined && flags['source-ref'] !== undefined) throw new Error('Choose --from or --source-ref, not both');
|
|
121
|
+
if (flags.from === 'reviewed-candidate') {
|
|
122
|
+
const checkpoint = job.continuation_status;
|
|
123
|
+
if (!checkpoint?.available) throw new Error(checkpoint?.reason || 'No current reviewed checkpoint is available; inspect status');
|
|
124
|
+
revision = { revision_mode: 'continue_candidate', candidate_head: checkpoint.head, candidate_tree: checkpoint.tree };
|
|
125
|
+
} else revision = { revision_mode: flags['source-ref'] === undefined ? 'fresh_source' : 'replace_source' };
|
|
119
126
|
feedback=readFileSync(resolve(flags.file),'utf8');
|
|
120
127
|
if(!feedback.trim()||feedback.length>4000)throw new Error('Provide revision feedback under 4000 characters');
|
|
121
128
|
}
|
|
122
129
|
// The controller validates the current run and owns reconciliation atomically.
|
|
123
130
|
// A CLI-side stop after a stale snapshot could terminate a newer attempt.
|
|
124
|
-
await api(state,`/api/v1/jobs/${id}/${action}`,{run_id:current?.id,...(feedback===undefined?{}:{feedback}),...(flags['source-ref']===undefined?{}:{source_ref:flags['source-ref']})});
|
|
131
|
+
await api(state,`/api/v1/jobs/${id}/${action}`,{run_id:current?.id,...revision,...(feedback===undefined?{}:{feedback}),...(flags['source-ref']===undefined?{}:{source_ref:flags['source-ref']})});
|
|
125
132
|
console.log(`${action}: ${id}`);
|
|
126
133
|
}
|
|
127
134
|
|
|
@@ -331,7 +338,7 @@ try {
|
|
|
331
338
|
publish JOB_ID Publish/reconcile the accepted candidate as one draft PR
|
|
332
339
|
abandon-delivery JOB_ID --branch-sha SHA Resolve an inspected pre-write collision; keep the remote branch
|
|
333
340
|
retry JOB_ID Prove stop; retain old checkout and retry
|
|
334
|
-
revise JOB_ID --file feedback.md [--source-ref REF]
|
|
341
|
+
revise JOB_ID --file feedback.md [--from admitted-source|reviewed-candidate | --source-ref REF]
|
|
335
342
|
New build/check/review; source stays pinned unless a new ref is explicit
|
|
336
343
|
version Show the active CLI version
|
|
337
344
|
update | update --check Update the npm CLI / inspect the latest release
|
package/docs/interfaces.md
CHANGED
|
@@ -16,7 +16,7 @@ shell endpoint or a second scheduler.
|
|
|
16
16
|
| Repository issue templates | `issue templates`, `issue draft --template --sha --file` | Authenticated template list and draft compilation | Chooser, fields/defaults/validation, review | Supports Markdown and YAML markdown/input/textarea/dropdown/checkboxes; unsupported templates link to GitHub |
|
|
17
17
|
| Suggest task type | `issue recommend --file` or `--url` | Authenticated `POST /api/v1/intake/recommend`; issue preview includes suggestion | Editable recommendation after source selection | Deterministic label/brief rules; no model judgment or execution authority |
|
|
18
18
|
| Cancel/retry/approve | Commands | Job action endpoints with current run ID | Task controls | JSON action results and consistent needs-attention outcomes |
|
|
19
|
-
| Request changes | `revise --file` | `request_changes`
|
|
19
|
+
| Request changes | `revise --file`, optional `--from admitted-source\|reviewed-candidate` or `--source-ref REF` | `request_changes` with `revision_mode`; continuation binds current run/head/tree from shared status | Explicit starting point, baseline and reviewed checkpoint; inline errors | JSON action result; unfinished Build checkpoints remain #42 |
|
|
20
20
|
| Remove a stopped task | No command | `DELETE /api/v1/jobs/:id` | Remove action | Add CLI; keep existing recoverability/history semantics |
|
|
21
21
|
| Evidence list/read/download | No command | Authenticated artifact routes | Files/preview/download | Add CLI with matching access and size/path rules |
|
|
22
22
|
| Roles, workflows and packaged skills | `definition`, `agents`, `skills` JSON (also while stopped) | `GET /api/v1/definitions` | Agents, Skills and Definition | Shared read-only catalog; future editing must preserve common policy/gates |
|
|
@@ -32,7 +32,7 @@ shell endpoint or a second scheduler.
|
|
|
32
32
|
| SSH tunnels | `tunnel` | No tunnel endpoint | None | Client-host ownership; distinguish operator machine from worker |
|
|
33
33
|
| Method export | `kit --output` | No export endpoint | None | Equivalent download/export preserving staging-only adoption |
|
|
34
34
|
| Synthetic qualification | `demo`, `qualify` | No qualification endpoint | Synthetic disclosure only | Explicit separate state; never target an application accidentally |
|
|
35
|
-
| Immutable source admission | `init --source-ref`, `run --source-ref`, `issue start --source-ref`; status and build evidence carry the resolved SHA | `POST /api/v1/jobs` resolves/retains before acknowledgement; shared source metadata in status | New issue and revision forms accept a ref; task detail shows requested ref, resolved SHA and prior source commits | Build/retry use retained objects; revisions
|
|
35
|
+
| Immutable source admission | `init --source-ref`, `run --source-ref`, `issue start --source-ref`; status and build evidence carry the resolved SHA | `POST /api/v1/jobs` resolves/retains before acknowledgement; shared source metadata in status | New issue and revision forms accept a ref; task detail shows requested ref, resolved SHA and prior source commits | Build/retry use retained objects; revisions start fresh by default; explicit continuation keeps the reviewed tree and recorded source; a new ref replaces the base; legacy source remains unknown |
|
|
36
36
|
| Trusted PR handoff | `publish JOB_ID` publishes/reconciles; `abandon-delivery JOB_ID --branch-sha SHA` records a checked local resolution for a pre-write branch collision | Authenticated `POST /api/v1/jobs/:id/publish` and `/abandon-delivery`; shared receipt, conflict identity and removal policy | Publish/reconcile and explicit “Abandon local delivery; keep remote branch” actions share controller state; errors/results and inspected branch identity are visible | New writes require matching protected Codex/Pi build/review provenance, deterministic verify/handoff provenance, non-synthetic bound artifacts and a qualified GitHub Actions tree. Shared delivery status exposes `workflow_qualification` and the same reason blocks CLI/API/dashboard capability and publication/retry. Candidate workflow changes, unsupported triggers/syntax, or active generated-push, selected-ref-dispatch and PR jobs with write/secrets/environment/OIDC/deploy access, self-hosted runners or ambiguous privileged guards refuse trusted writes. Supported ASCII guard comparisons follow GitHub's case-insensitive string semantics; unknown PR refs, non-ASCII mismatches, and glob/escaped branch filters cannot prove a privileged job inactive. The shared summary's `action_mode` distinguishes new/resumable publication from read-only reconciliation and drives idle and pending task button wording. Branch-only collisions and unknown/abandoned states offer neither; known PR receipts and pending PR-creation checkpoints retain read-only reconciliation. Abandonment checks the current run, saved intent, exact branch head and absence of an associated PR; it writes no provider data, preserves the remote branch/evidence, disables republishing and permits local removal. Uncertain effects and incompatible evidence stay blocked. Destination remains private operator config; patch-only remains default |
|
|
37
37
|
| Optional trusted web verification | `web probe` performs a real local Chromium interaction; `doctor` reports readiness | Verify stores a shared story summary and protected JSON artifact in the run | Task history shows passed/failed/unavailable/inconclusive plus tool, candidate, policy and story hashes | Disabled by default. Required operator stories and Playwright/Chromium image ID are frozen in attempt policy. Linux Chromium proof cannot qualify native/mobile OS behavior; see [the browser contract](web-verification.md) |
|
|
38
38
|
|
package/docs/npm.md
CHANGED
|
@@ -73,8 +73,8 @@ installation or retained attempt needs them.
|
|
|
73
73
|
|
|
74
74
|
## Protected evidence compatibility
|
|
75
75
|
|
|
76
|
-
0.
|
|
77
|
-
0.9.0 and 0.
|
|
76
|
+
0.10.0 recognizes version-1 execution profiles emitted by native **0.8.0,
|
|
77
|
+
0.9.0, 0.9.1 and 0.10.0**. This is an exact allowlist in
|
|
78
78
|
`factory/execution-profile.mjs`, independent of the installed package version;
|
|
79
79
|
it is not a semver range or an automatic promise for later releases. Unknown
|
|
80
80
|
runtime strings, unknown profile formats and incomplete legacy acceptance
|
|
@@ -101,12 +101,25 @@ neither a reason to discard evidence nor proof of compatibility. Retain
|
|
|
101
101
|
unsupported records unchanged and obtain fresh applicable evidence through the
|
|
102
102
|
normal workflow; never repair them by editing private records or hashes.
|
|
103
103
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
104
|
+
The 0.10.0 writer retains the same profile format and original-base, aggregate
|
|
105
|
+
single-parent candidate/check/review/approval guarantees. Continuation adds
|
|
106
|
+
separate provenance; it is not acceptance evidence. The audited 0.8.0, 0.9.0 and
|
|
107
|
+
0.9.1 writers remain supported only when their old records meet all current
|
|
108
|
+
checks. For continuation specifically, the current completed review, successful
|
|
109
|
+
Build/Verify, protected per-attempt artifacts, current policy and clean candidate
|
|
110
|
+
objects must also be present and agree. An old runtime string alone never makes
|
|
111
|
+
a checkpoint usable. Browser-disabled 0.8.0 records cannot satisfy a newly
|
|
112
|
+
enabled browser policy. Missing or unsupported records stay unchanged and
|
|
113
|
+
unavailable; no schema migration, profile relabeling or policy repair occurs.
|
|
114
|
+
|
|
115
|
+
#71 shipped 0.9.0 through PR #81 and #84 shipped 0.9.1 through PR #85; both
|
|
116
|
+
passed installed qualification, as reported by the lead. For this 0.10.0 slice,
|
|
117
|
+
native Verify/Review remain required. The lead owns installed continuation,
|
|
118
|
+
disposable protected PR/CI and desktop/narrow light/dark browser qualification
|
|
119
|
+
on Z13. Controlled source fixtures do not supply that proof. New publication
|
|
120
|
+
still requires the current remote target to equal the original accepted base;
|
|
121
|
+
reviewed-candidate continuation preserves that baseline, while target refresh
|
|
122
|
+
remains #72.
|
|
110
123
|
|
|
111
124
|
## Release flow
|
|
112
125
|
|
package/docs/recovery.md
CHANGED
|
@@ -8,7 +8,7 @@ Use `status --state PATH` and the private supervisor.log to identify the active
|
|
|
8
8
|
- `retry JOB_ID` verifies retained objects and reconciles the previous process group and containers. A missing/corrupt retained source, live writer or unknown process blocks retry. For a new build/defence attempt, the prior checkout is retained as previous-checkout-*.
|
|
9
9
|
- A forced host/controller stop can leave the selected inference env file in the private attempt directory. Normal executor cleanup removes it only after a Docker listing confirms the named container is absent. Retry/reconciliation removes a leftover copy only after the prior process group is confirmed stopped and a Docker listing confirms the labelled containers are absent; a `docker rm` or `docker stop` exit alone does not prove shutdown. If identity, a Docker probe or shutdown is uncertain, the recovery fence and file remain; do not clean it manually while a worker may still be active. After shutdown is confirmed, recovery also redacts the fixed phase reports and log using the selected environment values before removing the env file.
|
|
10
10
|
- Retry repeats the stopped phase. A failed verification may retry the same candidate when its policy is unchanged and the check can now pass. If the check or execution policy changed after build, retrying verify/review cannot reuse the earlier build for handoff; handoff remains blocked. Preserve that failed attempt, then use an eligible requested revision or submit a replacement task to build under the current policy. Do not edit SQLite or acceptance evidence to bypass the guard.
|
|
11
|
-
- A requested revision
|
|
11
|
+
- A requested revision starts fresh from the recorded source by default, retains previous evidence and starts a new build with accumulated feedback. It never silently reuses candidate code. Supplying a deliberate new `--source-ref` resolves and retains that base before the action; prior source metadata stays in history, and the new build gets fresh checks and review. See the explicit continuation choice below.
|
|
12
12
|
- Removing a stopped task from the dashboard hides its queue record. Private artifacts and its deleted_at record remain on disk; this is not secure erasure.
|
|
13
13
|
|
|
14
14
|
Do not remove active.json merely to unblock a job. Establish that its PID, process group and labelled containers are stopped. PID reuse or missing process identity requires operator investigation. Preserve logs and work before cleanup.
|
|
@@ -331,3 +331,69 @@ candidate or policy change requires fresh applicable checks, review and
|
|
|
331
331
|
approval; do not edit SQLite or acceptance evidence to bypass the guard. A stale
|
|
332
332
|
target after branch creation leaves that unique branch for inspection and does
|
|
333
333
|
not open a PR. A PR does not imply integration or deployment.
|
|
334
|
+
|
|
335
|
+
## Continue a reviewed candidate (first #42 slice)
|
|
336
|
+
|
|
337
|
+
Use one explicit starting point for a current software review with a returned
|
|
338
|
+
`changes`/`blocked` verdict, or a passed review awaiting approval:
|
|
339
|
+
|
|
340
|
+
```sh
|
|
341
|
+
# Existing default: restore admitted source, discarding candidate code from the next Build.
|
|
342
|
+
software-defence-factory revise JOB_ID --file feedback.md --state PATH
|
|
343
|
+
# Keep this job’s current immutable reviewed tree; keep its source/delivery baseline.
|
|
344
|
+
software-defence-factory revise JOB_ID --file feedback.md --from reviewed-candidate --state PATH
|
|
345
|
+
# Existing source replacement: resolve and retain a deliberately different baseline.
|
|
346
|
+
software-defence-factory revise JOB_ID --file feedback.md --source-ref REF --state PATH
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
`--from admitted-source` names the default explicitly. `--from` and
|
|
350
|
+
`--source-ref` cannot be combined. In the dashboard, Request changes exposes
|
|
351
|
+
these same three starting points, the original source SHA, selected checkpoint
|
|
352
|
+
head/tree and review attempt. Opening the form defaults to fresh source; a
|
|
353
|
+
background refresh does not silently replace a checkpoint already selected.
|
|
354
|
+
Errors stay next to the revision action and preserve feedback.
|
|
355
|
+
|
|
356
|
+
The shared `request_changes` action accepts `run_id`, `feedback`, and
|
|
357
|
+
`revision_mode`: `fresh_source`, `continue_candidate` or `replace_source`.
|
|
358
|
+
Continuation additionally requires `candidate_head` and `candidate_tree` from
|
|
359
|
+
this job’s `continuation_status` in `GET /api/v1/status`. They are stale-action
|
|
360
|
+
guards, not selectors for arbitrary refs, host paths or other jobs. Replacement
|
|
361
|
+
requires `source_ref`. Omitting mode preserves the existing default and
|
|
362
|
+
`source_ref` behavior. Status includes `revision_mode`, public `continuation`
|
|
363
|
+
provenance, and `continuation_status.available` or an actionable refusal reason.
|
|
364
|
+
Availability describes protected evidence; the action also probes live workers
|
|
365
|
+
and validates the Git objects before changing the checkout.
|
|
366
|
+
|
|
367
|
+
Continuation confirms the executor/process group and job containers are idle;
|
|
368
|
+
it refuses active or unknown work instead of stopping it implicitly. Under the
|
|
369
|
+
existing queue action lock it validates the current review cycle, exact
|
|
370
|
+
protected profiles and artifacts, original retained source, effective policy
|
|
371
|
+
and any required browser evidence. It copies the clean reviewed Git objects
|
|
372
|
+
into a private, job-bound checkpoint store with a pinned ref and manifest,
|
|
373
|
+
checks head/tree/single parent and object integrity, and only then archives the
|
|
374
|
+
writable checkout through ordinary reconciliation. Missing, dirty, tampered,
|
|
375
|
+
foreign or incompatible candidates are refused without deleting prior work.
|
|
376
|
+
|
|
377
|
+
The next Build validates the retained checkpoint and current policy again,
|
|
378
|
+
restores the retained source as HEAD, and stages the checkpoint tree on that
|
|
379
|
+
base. The ordinary Build commit is a single-parent aggregate: Review sees the
|
|
380
|
+
entire original-base-to-new-candidate diff, including earlier changes. The
|
|
381
|
+
original source admission is unchanged; continuation provenance has separate
|
|
382
|
+
names and records on the revision, job, candidate and acceptance. Prior failed
|
|
383
|
+
reviews stay failed, with their usage and artifacts retained. A stopped Build
|
|
384
|
+
can retry the same selected immutable checkpoint after validation; it cannot
|
|
385
|
+
reuse unfinished Build output. A restart never grants acceptance or triggers
|
|
386
|
+
an automatic retry.
|
|
387
|
+
|
|
388
|
+
Every revision requires new full Verify, independent Review and operator
|
|
389
|
+
approval. Browser and profile gates still apply. Delivery requires the original
|
|
390
|
+
accepted base to equal the configured, unchanged remote target. Continuation
|
|
391
|
+
does not refresh that target (#72), rewrite history, merge or publish anything.
|
|
392
|
+
Changed execution policy requires an explicit fresh start or source replacement,
|
|
393
|
+
not reuse of an incompatible checkpoint. Restore missing private retention from
|
|
394
|
+
backup before retrying; never edit checkpoint manifests or acceptance records.
|
|
395
|
+
|
|
396
|
+
Unfinished/uncommitted Build checkpoints, exit-cause classification, live pending
|
|
397
|
+
feedback and harness deadline hints remain deferred within #42. Opt-in desktop/VM
|
|
398
|
+
observation and browser recordings belong to later #82. This slice does not close
|
|
399
|
+
#42 or qualify those capabilities.
|
package/factory/delivery.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
+
import { readPrivateJson, assertPrivateDirectory, trustedExecutionProfile } from './execution-evidence.mjs';
|
|
1
2
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
3
|
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
4
|
import { join, resolve } from 'node:path';
|
|
4
5
|
import { spawnSync } from 'node:child_process';
|
|
5
|
-
import { isDeepStrictEqual } from 'node:util';
|
|
6
6
|
import { configAt, digest } from './lib.mjs';
|
|
7
7
|
import { effectiveExecutionConfig, isSupportedExecutionProfile } from './execution-profile.mjs';
|
|
8
8
|
import { publicSourceAdmission } from './source-admission.mjs';
|
|
@@ -15,7 +15,6 @@ const MAX_PATCH_BYTES = 8 * 1024 * 1024;
|
|
|
15
15
|
const MAX_CHANGED_FILES = 500;
|
|
16
16
|
const MAX_TREE_BYTES = 8 * 1024 * 1024;
|
|
17
17
|
const SHA1 = /^[a-f0-9]{40}$/;
|
|
18
|
-
const PRIVATE_JSON_BYTES = 1024 * 1024;
|
|
19
18
|
const utf8 = new TextDecoder('utf-8', { fatal: true });
|
|
20
19
|
const KNOWN_DELIVERY_STATES = new Set(['intent', 'publishing', 'uncertain', 'blocked', 'conflict', 'published', 'abandoned']);
|
|
21
20
|
const RESUMABLE_DELIVERY_STATES = new Set(['intent', 'publishing', 'uncertain', 'blocked']);
|
|
@@ -55,23 +54,12 @@ function commitObjectSha({ tree, parents, message, author, committer }) {
|
|
|
55
54
|
const raw = Buffer.from(`tree ${tree}\nparent ${parents[0]}\nauthor ${author.name} <${author.email}> ${date.epoch} +0000\ncommitter ${committer.name} <${committer.email}> ${date.epoch} +0000\n\n${message}`, 'utf8');
|
|
56
55
|
return createHash('sha1').update(`commit ${raw.length}\0`).update(raw).digest('hex');
|
|
57
56
|
}
|
|
58
|
-
function readPrivateJson(path) {
|
|
59
|
-
const stat = lstatSync(path);
|
|
60
|
-
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > PRIVATE_JSON_BYTES)
|
|
61
|
-
throw new Error('Protected delivery evidence is missing or unsafe.');
|
|
62
|
-
return JSON.parse(readFileSync(path, 'utf8'));
|
|
63
|
-
}
|
|
64
57
|
function readPrivateFile(path, limit) {
|
|
65
58
|
const stat = lstatSync(path);
|
|
66
59
|
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > limit)
|
|
67
60
|
throw new Error('Protected candidate patch is missing, unsafe or too large.');
|
|
68
61
|
return readFileSync(path);
|
|
69
62
|
}
|
|
70
|
-
function assertPrivateDirectory(path) {
|
|
71
|
-
const stat = lstatSync(path);
|
|
72
|
-
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0)
|
|
73
|
-
throw new Error('Protected delivery directory is missing or unsafe.');
|
|
74
|
-
}
|
|
75
63
|
function runGitCommand(args) {
|
|
76
64
|
const env = {
|
|
77
65
|
PATH: process.env.PATH || '/usr/bin:/bin', HOME: '/nonexistent',
|
|
@@ -207,25 +195,6 @@ function remotePullReceipt(pull, repository, target, branch) {
|
|
|
207
195
|
};
|
|
208
196
|
}
|
|
209
197
|
|
|
210
|
-
function trustedExecutionProfile(state, job, run, phase, expectedPolicy) {
|
|
211
|
-
if (!run || !/^run_[a-z0-9]+$/.test(run.id || '')) return false;
|
|
212
|
-
try {
|
|
213
|
-
const folder = join(state, 'jobs', job.id);
|
|
214
|
-
for (const path of [join(state, 'jobs'), folder, join(folder, 'artifacts'), join(folder, 'artifacts', run.id)])
|
|
215
|
-
assertPrivateDirectory(path);
|
|
216
|
-
const profile = readPrivateJson(join(folder, 'artifacts', run.id, 'execution.json'));
|
|
217
|
-
const agentPhase = phase === 'build' || phase === 'review';
|
|
218
|
-
const executorMatches = agentPhase
|
|
219
|
-
? ['codex', 'pi'].includes(profile.executor)
|
|
220
|
-
&& ['explicit', 'provider_default'].includes(profile.modelSelection)
|
|
221
|
-
&& (profile.modelSelection === 'explicit' ? typeof profile.requestedModel === 'string' : profile.requestedModel === null)
|
|
222
|
-
: profile.executor === 'deterministic' && profile.modelSelection === 'not_applicable' && profile.requestedModel === null;
|
|
223
|
-
return isSupportedExecutionProfile(profile) && profile.phase === phase && executorMatches
|
|
224
|
-
&& profile.policyHash === expectedPolicy
|
|
225
|
-
&& isDeepStrictEqual(run.execution, profile);
|
|
226
|
-
} catch { return false; }
|
|
227
|
-
}
|
|
228
|
-
|
|
229
198
|
function trustedPhaseEvidence(state, job, expectedPolicy, runs, candidate, checks, review) {
|
|
230
199
|
const executionsMatch = trustedExecutionProfile(state, job, runs.build, 'build', expectedPolicy)
|
|
231
200
|
&& trustedExecutionProfile(state, job, runs.verify, 'verify', expectedPolicy)
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
4
|
+
import { configAt, digest } from './lib.mjs';
|
|
5
|
+
import { effectiveExecutionConfig, isSupportedExecutionProfile } from './execution-profile.mjs';
|
|
6
|
+
import { assertCurrentWebArtifacts, assertCurrentWebEvidence } from './web-verification.mjs';
|
|
2
7
|
|
|
3
8
|
export function assertCurrentHandoffEvidence(meta, checks, review, policyHash, config = {}, job) {
|
|
4
9
|
if (review.verdict !== 'pass' || review.head !== meta.head || review.tree !== meta.tree
|
|
@@ -9,3 +14,93 @@ export function assertCurrentHandoffEvidence(meta, checks, review, policyHash, c
|
|
|
9
14
|
if (config.webVerification?.enabled) assertCurrentWebEvidence(config.webVerification, checks.web_verification,
|
|
10
15
|
{ job, attempt: checks.run_id, meta, policyHash });
|
|
11
16
|
}
|
|
17
|
+
|
|
18
|
+
export function readPrivateJson(path) {
|
|
19
|
+
const stat = lstatSync(path);
|
|
20
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > 1024 * 1024)
|
|
21
|
+
throw new Error('Protected execution evidence is missing or unsafe.');
|
|
22
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function assertPrivateDirectory(path) {
|
|
26
|
+
const stat = lstatSync(path);
|
|
27
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0)
|
|
28
|
+
throw new Error('Protected execution directory is missing or unsafe.');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function trustedExecutionProfile(state, job, run, phase, expectedPolicy) {
|
|
32
|
+
if (!run || !/^run_[a-z0-9]+$/.test(run.id || '')) return false;
|
|
33
|
+
try {
|
|
34
|
+
const folder = join(state, 'jobs', job.id);
|
|
35
|
+
for (const path of [join(state, 'jobs'), folder, join(folder, 'artifacts'), join(folder, 'artifacts', run.id)])
|
|
36
|
+
assertPrivateDirectory(path);
|
|
37
|
+
const profile = readPrivateJson(join(folder, 'artifacts', run.id, 'execution.json'));
|
|
38
|
+
const agentPhase = phase === 'build' || phase === 'review';
|
|
39
|
+
const executorMatches = agentPhase
|
|
40
|
+
? ['codex', 'pi'].includes(profile.executor)
|
|
41
|
+
&& ['explicit', 'provider_default'].includes(profile.modelSelection)
|
|
42
|
+
&& (profile.modelSelection === 'explicit' ? typeof profile.requestedModel === 'string' : profile.requestedModel === null)
|
|
43
|
+
: profile.executor === 'deterministic' && profile.modelSelection === 'not_applicable' && profile.requestedModel === null;
|
|
44
|
+
return isSupportedExecutionProfile(profile) && profile.phase === phase && executorMatches
|
|
45
|
+
&& profile.policyHash === expectedPolicy
|
|
46
|
+
&& isDeepStrictEqual(run.execution, profile);
|
|
47
|
+
} catch { return false; }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function currentExecutionPolicy(state, job) {
|
|
51
|
+
const config = effectiveExecutionConfig(configAt(state), job.model, join(state, 'model.env'));
|
|
52
|
+
return { config, policyHash: digest(JSON.stringify(config)) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Only the current, completed review cycle may supply a continuation. Old writers
|
|
56
|
+
// can supply it only with the same protected profiles, full checks and bindings.
|
|
57
|
+
export function currentReviewedCandidate(state, job) {
|
|
58
|
+
const latest = job.runs.at(-1);
|
|
59
|
+
const reviewRun = job.state === 'awaiting_approval' && latest?.command === 'handoff'
|
|
60
|
+
? job.runs.find(run => run.id === latest.reviewed_run_id) : latest;
|
|
61
|
+
if (job.workflow?.name !== 'software' || !['failed', 'awaiting_approval'].includes(job.state)
|
|
62
|
+
|| reviewRun?.command !== 'review' || !['failed', 'succeeded'].includes(reviewRun.state))
|
|
63
|
+
throw new Error('Only this job’s current completed review can supply a checkpoint.');
|
|
64
|
+
const folder = join(state, 'jobs', job.id);
|
|
65
|
+
const meta = readPrivateJson(join(folder, 'candidate.json'));
|
|
66
|
+
const checks = readPrivateJson(join(folder, 'checks.json'));
|
|
67
|
+
const review = readPrivateJson(join(folder, 'review.json'));
|
|
68
|
+
const { config, policyHash } = currentExecutionPolicy(state, job);
|
|
69
|
+
const source = job.source_admission;
|
|
70
|
+
const buildRun = job.runs.find(run => run.id === meta.build_run_id);
|
|
71
|
+
const checkRun = job.runs.find(run => run.id === checks.run_id);
|
|
72
|
+
const cycle = job.runs.slice(0, job.runs.indexOf(reviewRun));
|
|
73
|
+
if (cycle.findLast(run => run.command === 'build')?.id !== buildRun?.id
|
|
74
|
+
|| cycle.findLast(run => run.command === 'verify')?.id !== checkRun?.id
|
|
75
|
+
|| job.runs.indexOf(buildRun) >= job.runs.indexOf(checkRun))
|
|
76
|
+
throw new Error('Checkpoint is not from the current reviewed Build/Verify cycle.');
|
|
77
|
+
for (const [run, phase] of [[buildRun, 'build'], [checkRun, 'verify'], [reviewRun, 'review']]) {
|
|
78
|
+
if (!trustedExecutionProfile(state, job, run, phase, policyHash)
|
|
79
|
+
|| (phase !== 'review' && (run?.state !== 'succeeded' || run.outcome !== 'complete')))
|
|
80
|
+
throw new Error('Checkpoint execution provenance or current policy is incompatible; start fresh from admitted source.');
|
|
81
|
+
}
|
|
82
|
+
if (source?.status !== 'retained' || meta.base !== source.resolved_sha
|
|
83
|
+
|| meta.source_admission?.resolved_sha !== source.resolved_sha
|
|
84
|
+
|| meta.source_admission?.repository_identity !== source.repository_identity
|
|
85
|
+
|| meta.build_policy_hash !== policyHash || meta.synthetic !== false || checks.synthetic !== false || review.synthetic !== false
|
|
86
|
+
|| !checks.passed || checks.command !== config.check || checks.policyHash !== policyHash
|
|
87
|
+
|| checks.head !== meta.head || checks.tree !== meta.tree
|
|
88
|
+
|| review.run_id !== reviewRun.id || review.head !== meta.head || review.tree !== meta.tree
|
|
89
|
+
|| review.policyHash !== policyHash || !['pass', 'changes', 'blocked'].includes(review.verdict)
|
|
90
|
+
|| (reviewRun.review_verdict !== undefined && reviewRun.review_verdict !== review.verdict)
|
|
91
|
+
|| (job.state === 'failed' && (reviewRun.state !== 'failed' || !['changes', 'blocked'].includes(review.verdict)))
|
|
92
|
+
|| (job.state === 'awaiting_approval' && (reviewRun.state !== 'succeeded' || review.verdict !== 'pass')))
|
|
93
|
+
throw new Error('Checkpoint source, checks or review is stale or incompatible; reload or start fresh from admitted source.');
|
|
94
|
+
for (const [run, name, evidence] of [[buildRun, 'candidate', meta], [checkRun, 'checks', checks], [reviewRun, 'review', review]]) {
|
|
95
|
+
if (!isDeepStrictEqual(readPrivateJson(join(folder, 'artifacts', run.id, `${name}.json`)), evidence))
|
|
96
|
+
throw new Error('Checkpoint differs from its protected attempt evidence; preserve it for investigation.');
|
|
97
|
+
}
|
|
98
|
+
if (config.webVerification?.enabled) {
|
|
99
|
+
assertCurrentWebEvidence(config.webVerification, checks.web_verification,
|
|
100
|
+
{ job: job.id, attempt: checkRun.id, meta, policyHash });
|
|
101
|
+
assertCurrentWebArtifacts(config.webVerification, checks.web_verification, join(folder, 'artifacts', checkRun.id));
|
|
102
|
+
}
|
|
103
|
+
return { head: meta.head, tree: meta.tree, original_base: meta.base,
|
|
104
|
+
build_run_id: buildRun.id, checks_run_id: checkRun.id, review_run_id: reviewRun.id,
|
|
105
|
+
policy_hash: policyHash, repository_identity: source.repository_identity };
|
|
106
|
+
}
|
|
@@ -7,11 +7,11 @@ import { effectiveInferenceProvider } from './model-environment.mjs';
|
|
|
7
7
|
import { expectedWebStories, webPolicyHash } from './web-verification.mjs';
|
|
8
8
|
|
|
9
9
|
// Audited v1 protected evidence writers: 0.8.0 (380f749), 0.9.0 (cdaadef),
|
|
10
|
-
// and
|
|
10
|
+
// 0.9.1 and 0.10.0. Deliberately independent of VERSION: a release bump is not
|
|
11
11
|
// evidence compatibility. Re-audit this list for every trust-relevant writer,
|
|
12
12
|
// isolation or validation change; remove versions whose guarantees no longer
|
|
13
13
|
// satisfy current policy. See docs/npm.md. This predicate alone grants no trust.
|
|
14
|
-
const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1']);
|
|
14
|
+
const SUPPORTED_EXECUTION_RUNTIMES_V1 = new Set(['0.8.0', '0.9.0', '0.9.1', '0.10.0']);
|
|
15
15
|
|
|
16
16
|
export function isSupportedExecutionProfile(profile) {
|
|
17
17
|
return profile?.version === 1 && SUPPORTED_EXECUTION_RUNTIMES_V1.has(profile.runtimeVersion);
|
package/factory/executor.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { ROOT, run, save, json, digest, instanceLabel, stopContainers } from './
|
|
|
6
6
|
import { incidentFor, validateReport } from './incident.mjs';
|
|
7
7
|
import { BoundedLog } from './bounded-log.mjs';
|
|
8
8
|
import { CodexUsageParser, emptyUsage, usageFields } from './usage.mjs';
|
|
9
|
-
import { assertRetainedSource, publicSourceAdmission,
|
|
9
|
+
import { assertRetainedSource, publicSourceAdmission, publicContinuation, restoreBuildCheckout } from './source-admission.mjs';
|
|
10
10
|
import { runCandidateGit } from './git-environment.mjs';
|
|
11
11
|
import { selectedInferenceSecrets, writeSelectedModelEnvironment } from './model-environment.mjs';
|
|
12
12
|
import { redactInferenceText, redactRetainedPhaseOutputs } from './inference-redaction.mjs';
|
|
@@ -25,8 +25,8 @@ if (!['build','verify','review','handoff','defence'].includes(phase)) throw new
|
|
|
25
25
|
const folder = join(state, 'jobs', job), workspace = join(folder, 'checkout');
|
|
26
26
|
const config = json(join(folder, attempt, 'execution-config.json'));
|
|
27
27
|
const execution = json(join(folder, 'artifacts', attempt, 'execution.json'));
|
|
28
|
-
let sourceAdmission;
|
|
29
|
-
try { sourceAdmission = JSON.parse(process.env.SDF_SOURCE_ADMISSION || 'null'); }
|
|
28
|
+
let sourceAdmission, continuation;
|
|
29
|
+
try { sourceAdmission = JSON.parse(process.env.SDF_SOURCE_ADMISSION || 'null'); continuation = JSON.parse(process.env.SDF_CONTINUATION || 'null'); }
|
|
30
30
|
catch { throw new Error('Protected source admission metadata is malformed'); }
|
|
31
31
|
const policyHash = digest(JSON.stringify(config));
|
|
32
32
|
if (execution.policyHash !== policyHash || execution.phase !== phase) throw new Error('Admitted execution profile does not match this attempt');
|
|
@@ -50,7 +50,7 @@ function candidate() {
|
|
|
50
50
|
if (sourceAdmission?.status === 'retained' && (meta.base !== sourceAdmission.resolved_sha || meta.source_admission?.resolved_sha !== sourceAdmission.resolved_sha))
|
|
51
51
|
throw new Error('Candidate does not use this job’s retained source revision; previous evidence is invalid for the current base');
|
|
52
52
|
if (git('rev-parse','HEAD^{tree}') !== meta.tree) throw new Error('Candidate tree changed; previous evidence is invalid');
|
|
53
|
-
if (meta.head !== meta.base && git('rev-
|
|
53
|
+
if (meta.head !== meta.base && git('rev-list', '--parents', '-n', '1', meta.head) !== `${meta.head} ${meta.base}`)
|
|
54
54
|
throw new Error('Candidate parent differs from its admitted base; previous evidence is invalid');
|
|
55
55
|
return meta;
|
|
56
56
|
}
|
|
@@ -272,14 +272,16 @@ try {
|
|
|
272
272
|
if (existsSync(workspace)) throw new Error('Workspace already exists; preserve evidence and create a new task for a fresh build');
|
|
273
273
|
if (sourceAdmission?.status !== 'retained') throw new Error('Legacy job has no admission-time source revision and cannot build from the current checkout. Submit a replacement job to pin its source.');
|
|
274
274
|
const retained = assertRetainedSource(state, job, sourceAdmission);
|
|
275
|
-
|
|
275
|
+
restoreBuildCheckout(state, job, sourceAdmission, workspace, continuation, policyHash);
|
|
276
276
|
const head = git('rev-parse','HEAD');
|
|
277
277
|
if (head !== retained.sha) throw new Error('Build checkout differs from its admission-time source revision');
|
|
278
278
|
const baseTree = git('rev-parse', `${head}^{tree}`);
|
|
279
|
-
save(join(folder,'candidate.json'), { base: sourceAdmission.resolved_sha, head, tree: baseTree, source_admission: publicSourceAdmission(sourceAdmission), synthetic: harnessOf(config) === 'mock' });
|
|
279
|
+
save(join(folder,'candidate.json'), { base: sourceAdmission.resolved_sha, head, tree: baseTree, continuation: publicContinuation(continuation), source_admission: publicSourceAdmission(sourceAdmission), synthetic: harnessOf(config) === 'mock' });
|
|
280
280
|
}
|
|
281
281
|
if (phase === 'build') {
|
|
282
|
-
const
|
|
282
|
+
const continuationNote = continuation
|
|
283
|
+
? ` The checkout deliberately contains reviewed checkpoint ${continuation.head} (tree ${continuation.tree}, review ${continuation.review_run_id}) staged on original source ${continuation.original_base}. Retain those changes while addressing the requested revision; independent Review will cover the whole combined diff.` : '';
|
|
284
|
+
const reports = await container('build', brief('Implement the requested bounded change. Save /output/agent-report.md with actual changes and remaining uncertainty.' + continuationNote), config.command, { writable: true });
|
|
283
285
|
git('add','-A');
|
|
284
286
|
if (git('diff','--cached','--stat')) git('-c','user.name=Arcitai Factory','-c','user.email=factory@localhost','commit','--no-verify','-m','Factory candidate');
|
|
285
287
|
// Checks must cover the committed tree, not ignored build products supplied by the agent.
|
|
@@ -357,7 +359,7 @@ try {
|
|
|
357
359
|
writeFileSync(join(output,'handoff.md'), `Accepted candidate ${meta.head} (tree ${meta.tree}), based on source ${sourceSha}.\nPatch handoff remains available. Optional PR delivery is a separate explicit operator action when configured.\nNo integration, merge, release or deployment performed.\nSee docs/quickstart.md for applying the reviewed change.patch to your own branch.\n`);
|
|
358
360
|
save(join(folder,'accepted.json'), { base: meta.base, head: meta.head, tree: meta.tree, patch_sha256: patchHash,
|
|
359
361
|
build_run_id: meta.build_run_id, checks_run_id: checks.run_id, review_run_id: review.run_id,
|
|
360
|
-
handoff_run_id: attempt, policyHash, source_admission: meta.source_admission || publicSourceAdmission(null), acceptedAt: new Date().toISOString() });
|
|
362
|
+
handoff_run_id: attempt, policyHash, continuation: meta.continuation || null, source_admission: meta.source_admission || publicSourceAdmission(null), acceptedAt: new Date().toISOString() });
|
|
361
363
|
} else if (phase === 'defence') {
|
|
362
364
|
const reports = await container('defence', brief('Read-only incident triage. Use supplied evidence only; distinguish observations, hypotheses and unknowns. No live production access is configured. A 500 error is not inherently a security incident. Missing or stale telemetry remains unknown. Write /output/incident-report.json with status needs_review or insufficient_evidence, summary, hypotheses array, recommended_actions array, unknowns array and production_action_taken:false. Write /output/agent-report.md. Never claim root cause or recovery without supporting evidence.'), config.command);
|
|
363
365
|
const report = JSON.parse(safeRead(join(reports,'incident-report.json')));
|
package/factory/processes.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, mkdirSync, openSync, closeSync, readFileSync, renameSync, rmSync, lstatSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
|
-
import { ROOT, configAt, run, json, save, stopContainers, sleep } from './lib.mjs';
|
|
5
|
+
import { ROOT, configAt, run, json, save, stopContainers, containers, sleep } from './lib.mjs';
|
|
6
|
+
import { assertRetainedCheckpoint } from './source-admission.mjs';
|
|
6
7
|
import { effectiveExecutionConfig, executionProfile } from './execution-profile.mjs';
|
|
7
8
|
import { parseCodexJsonl, emptyUsage, usageFields } from './usage.mjs';
|
|
8
9
|
import { removeScratch } from './scratch.mjs';
|
|
@@ -98,6 +99,7 @@ export function executors(state, recovery = {}) {
|
|
|
98
99
|
const recoveryStopContainers = recovery.stopContainers || stopContainers;
|
|
99
100
|
const recoveryRun = recovery.run || run;
|
|
100
101
|
const recoveryAlive = recovery.alive || alive;
|
|
102
|
+
const recoveryContainers = recovery.containers || containers;
|
|
101
103
|
const usageCache = new Map();
|
|
102
104
|
let usageReadWindow = -1, usageReads = 0;
|
|
103
105
|
function presentedUsage(job, attempt) {
|
|
@@ -123,7 +125,10 @@ export function executors(state, recovery = {}) {
|
|
|
123
125
|
// Resolve tags before admission so the recorded image is the one actually run.
|
|
124
126
|
config.image = run('docker', ['image', 'inspect', '--format', '{{.Id}}', config.image]);
|
|
125
127
|
if (!/^sha256:[a-f0-9]{64}$/.test(config.image)) throw new Error('Expected an immutable Docker image ID');
|
|
126
|
-
const profile = executionProfile(config, attempt.command)
|
|
128
|
+
const profile = executionProfile(config, attempt.command);
|
|
129
|
+
if (attempt.command === 'build' && job.continuation)
|
|
130
|
+
assertRetainedCheckpoint(state, job.id, job.source_admission, job.continuation, profile.policyHash);
|
|
131
|
+
const folder = join(state, 'jobs', job.id);
|
|
127
132
|
save(join(folder, attempt.id, 'execution-config.json'), config);
|
|
128
133
|
save(join(folder, 'artifacts', attempt.id, 'execution.json'), profile);
|
|
129
134
|
return profile;
|
|
@@ -141,17 +146,21 @@ export function executors(state, recovery = {}) {
|
|
|
141
146
|
recoveryStopContainers(state, jobId);
|
|
142
147
|
if (running && !processAlive(-running.child.pid)) clearStoppedFence(state, jobId, running.child.pid);
|
|
143
148
|
}
|
|
144
|
-
async function reconcile(jobId, phase) {
|
|
149
|
+
async function reconcile(jobId, phase, { requireIdle = false, beforeArchive } = {}) {
|
|
145
150
|
if (children.has(jobId)) throw new Error('Previous executor is still finishing');
|
|
146
|
-
|
|
151
|
+
if (requireIdle && recoveryContainers(state).some(item => item.Config?.Labels?.['sdf.job'] === jobId && item.State?.Running !== false))
|
|
152
|
+
throw new Error('Previous job container is still active; stop it explicitly before continuing.');
|
|
153
|
+
if (!requireIdle) recoveryStopContainers(state, jobId);
|
|
147
154
|
const folder = join(state, 'jobs', jobId), lock = join(folder, 'active.json');
|
|
148
155
|
if (existsSync(lock)) {
|
|
149
156
|
const previous = json(lock);
|
|
150
157
|
if (!Number.isSafeInteger(previous.pid) || recoveryAlive(previous.pid)) throw new Error('Previous executor is still present or unknown');
|
|
151
158
|
if (!Number.isSafeInteger(previous.pgid) || recoveryRun('ps', ['-axo', 'pgid=']).split('\n').map(Number).includes(previous.pgid))
|
|
152
159
|
throw new Error('Previous process group is still present or unknown');
|
|
160
|
+
if (requireIdle) recoveryStopContainers(state, jobId);
|
|
153
161
|
clearStoppedFence(state, jobId);
|
|
154
|
-
}
|
|
162
|
+
} else if (requireIdle) recoveryStopContainers(state, jobId);
|
|
163
|
+
beforeArchive?.();
|
|
155
164
|
if (['build', 'defence'].includes(phase) && existsSync(join(folder, 'checkout')))
|
|
156
165
|
renameSync(join(folder, 'checkout'), join(folder, `previous-checkout-${Date.now()}`));
|
|
157
166
|
}
|
|
@@ -161,7 +170,7 @@ export function executors(state, recovery = {}) {
|
|
|
161
170
|
const resultPath = join(output, 'result.json'), fd = openSync(join(output, 'executor.log'), 'a', 0o600);
|
|
162
171
|
const child = spawnChild(process.execPath, [join(ROOT, 'factory/executor.mjs'), state, attempt.command], {
|
|
163
172
|
detached: true, stdio: ['pipe', fd, fd], env: { ...process.env, SDF_JOB_ID: job.id, SDF_RUN_ID: attempt.id, SDF_OUTPUT_DIR: output, SDF_STEP_RESULT_PATH: resultPath,
|
|
164
|
-
SDF_SOURCE_ADMISSION: JSON.stringify(job.source_admission || null) },
|
|
173
|
+
SDF_SOURCE_ADMISSION: JSON.stringify(job.source_admission || null), SDF_CONTINUATION: JSON.stringify(job.continuation || null) },
|
|
165
174
|
}); closeSync(fd);
|
|
166
175
|
let finish;
|
|
167
176
|
const done = new Promise(resolve => { finish = resolve; });
|
package/factory/queue.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { currentReviewedCandidate, currentExecutionPolicy } from './execution-evidence.mjs';
|
|
2
|
+
import { publicContinuation } from './source-admission.mjs';
|
|
1
3
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
4
|
import { randomBytes } from 'node:crypto';
|
|
3
5
|
import { join } from 'node:path';
|
|
@@ -13,6 +15,7 @@ export class QueueError extends Error { constructor(message, status = 409) { sup
|
|
|
13
15
|
// is committed before execution starts; a restart never assumes a result.
|
|
14
16
|
export class JobQueue {
|
|
15
17
|
constructor(state, { execute, stop, reconcile, prepare, reviewVerdict = () => undefined, sourceAdmission }) {
|
|
18
|
+
this.state = state;
|
|
16
19
|
this.db = new DatabaseSync(join(state, 'jobs.sqlite'));
|
|
17
20
|
this.db.exec('PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS jobs(id TEXT PRIMARY KEY, data TEXT NOT NULL);');
|
|
18
21
|
this.execute = execute; this.stop = stop; this.reconcile = reconcile; this.prepare = prepare; this.reviewVerdict = reviewVerdict; this.sourceAdmission = sourceAdmission;
|
|
@@ -124,6 +127,12 @@ export class JobQueue {
|
|
|
124
127
|
return job.state === 'failed' && attempt?.state === 'failed' && attempt.command === 'review'
|
|
125
128
|
&& ['changes', 'blocked'].includes(attempt.review_verdict ?? this.reviewVerdict(job, attempt));
|
|
126
129
|
}
|
|
130
|
+
continuationStatus(job) {
|
|
131
|
+
const originalBase = job.source_admission?.resolved_sha || null;
|
|
132
|
+
if (!this.canRequestChanges(job)) return { available: false, original_base: originalBase, reason: 'A current completed software review is required.' };
|
|
133
|
+
try { return { available: true, ...currentReviewedCandidate(this.state, job) }; }
|
|
134
|
+
catch (error) { return { available: false, original_base: originalBase, reason: error.code ? 'Protected reviewed checkpoint evidence is missing or unreadable; restore it or start fresh explicitly.' : error.message }; }
|
|
135
|
+
}
|
|
127
136
|
async applyAction(jobId, action, input) {
|
|
128
137
|
if (this.closing) throw new QueueError('Controller is stopping');
|
|
129
138
|
let job = this.get(jobId), attempt = job.runs.at(-1);
|
|
@@ -138,7 +147,21 @@ export class JobQueue {
|
|
|
138
147
|
if (typeof input.feedback !== 'string' || !input.feedback.trim() || input.feedback.length > 4000) throw new QueueError('Provide revision feedback under 4000 characters', 400);
|
|
139
148
|
const revisedPrompt = job.prompt + `\n\nRequested revision: ${input.feedback}`;
|
|
140
149
|
if (Buffer.byteLength(revisedPrompt) > 240000) throw new QueueError('Accumulated revision instructions exceed 240 KB; create a bounded continuation task', 400);
|
|
150
|
+
if (this.active?.jobId === jobId) throw new QueueError('Previous executor is still finishing; reload before acting');
|
|
141
151
|
const explicitSource = input.source_ref !== undefined;
|
|
152
|
+
const mode = input.revision_mode ?? (explicitSource ? 'replace_source' : 'fresh_source');
|
|
153
|
+
if (!['fresh_source', 'replace_source', 'continue_candidate'].includes(mode)
|
|
154
|
+
|| (explicitSource !== (mode === 'replace_source')))
|
|
155
|
+
throw new QueueError('Choose fresh_source, continue_candidate, or replace_source with source_ref; choices cannot be combined.', 400);
|
|
156
|
+
let continuation = null;
|
|
157
|
+
if (mode === 'continue_candidate') {
|
|
158
|
+
const selected = this.continuationStatus(job);
|
|
159
|
+
if (!selected.available) throw new QueueError(selected.reason);
|
|
160
|
+
if (input.candidate_head !== selected.head || input.candidate_tree !== selected.tree)
|
|
161
|
+
throw new QueueError('Reviewed candidate changed; reload and select the current checkpoint before continuing.');
|
|
162
|
+
if (typeof this.sourceAdmission?.retainCheckpoint !== 'function')
|
|
163
|
+
throw new QueueError('Reviewed checkpoint retention is unavailable.', 503);
|
|
164
|
+
}
|
|
142
165
|
if (typeof this.sourceAdmission?.validate !== 'function') throw new QueueError('Retained source validation is unavailable; this job was not revised.', 503);
|
|
143
166
|
const admittedSource = explicitSource
|
|
144
167
|
? this.sourceAdmission.admit(job.id, input.source_ref, job.source_admission.repository_identity)
|
|
@@ -152,19 +175,34 @@ export class JobQueue {
|
|
|
152
175
|
}
|
|
153
176
|
const replaceSourceRecord = changedBase || repairRetainedSource;
|
|
154
177
|
const nextSource = replaceSourceRecord ? admittedSource : job.source_admission;
|
|
155
|
-
try {
|
|
178
|
+
try {
|
|
179
|
+
await this.reconcile(jobId, 'build', mode === 'continue_candidate' ? {
|
|
180
|
+
requireIdle: true,
|
|
181
|
+
beforeArchive: () => {
|
|
182
|
+
const selected = currentReviewedCandidate(this.state, job);
|
|
183
|
+
if (input.candidate_head !== selected.head || input.candidate_tree !== selected.tree)
|
|
184
|
+
throw new QueueError('Reviewed candidate changed; reload before continuing.');
|
|
185
|
+
continuation = this.sourceAdmission.retainCheckpoint(job, selected);
|
|
186
|
+
},
|
|
187
|
+
} : undefined);
|
|
188
|
+
if (mode === 'continue_candidate' && !continuation)
|
|
189
|
+
throw new QueueError('Executor reconciliation did not retain the selected checkpoint; revision was not queued.', 503);
|
|
190
|
+
}
|
|
156
191
|
catch (error) {
|
|
157
192
|
if (explicitSource) this.sourceAdmission.release?.(job.id, admittedSource);
|
|
193
|
+
if (mode === 'continue_candidate') throw new QueueError(error.code ? 'Cannot continue: protected checkpoint storage is unreadable; prior work is preserved.' : error.message, error.status || 409);
|
|
158
194
|
throw error;
|
|
159
195
|
}
|
|
160
196
|
if (explicitSource && !replaceSourceRecord) this.sourceAdmission.release?.(job.id, admittedSource);
|
|
161
197
|
// A failed review stays failed. Revision feedback must not rewrite its result.
|
|
162
|
-
attempt.revision = { feedback: input.feedback, requested_at: now(), ...(explicitSource ? { requested_source_ref: admittedSource.requested_ref, resolved_source_sha: admittedSource.resolved_sha } : {}), ...(changedBase ? { previous_source_sha: job.source_admission.resolved_sha, new_source_sha: nextSource.resolved_sha } : {}) };
|
|
198
|
+
attempt.revision = { feedback: input.feedback, requested_at: now(), mode, original_base: nextSource.resolved_sha, continuation: publicContinuation(continuation), ...(explicitSource ? { requested_source_ref: admittedSource.requested_ref, resolved_source_sha: admittedSource.resolved_sha } : {}), ...(changedBase ? { previous_source_sha: job.source_admission.resolved_sha, new_source_sha: nextSource.resolved_sha } : {}) };
|
|
163
199
|
if (job.state === 'awaiting_approval') Object.assign(attempt, { state: 'succeeded', outcome: 'changes_requested', summary: input.feedback, completed_at: now(), duration_millis: 0 });
|
|
164
200
|
if (replaceSourceRecord) {
|
|
165
201
|
job.source_history = [...(job.source_history || []), job.source_admission];
|
|
166
202
|
job.source_admission = nextSource;
|
|
167
203
|
}
|
|
204
|
+
job.continuation = continuation;
|
|
205
|
+
job.revision_mode = mode;
|
|
168
206
|
job.prompt = revisedPrompt;
|
|
169
207
|
job.workflow.current_step = 0; job.state = 'queued';
|
|
170
208
|
try { this.save(job); }
|
|
@@ -184,6 +222,8 @@ export class JobQueue {
|
|
|
184
222
|
if (!['failed', 'interrupted', 'cancelled'].includes(job.state)) throw new QueueError('Only a stopped attempt can be retried');
|
|
185
223
|
if (typeof this.sourceAdmission?.validate !== 'function') throw new QueueError('Retained source validation is unavailable; this job was not retried.', 503);
|
|
186
224
|
this.sourceAdmission.validate(job.id, job.source_admission);
|
|
225
|
+
if (attempt?.command === 'build' && job.continuation)
|
|
226
|
+
this.sourceAdmission.validateCheckpoint(job.id, job.source_admission, job.continuation, currentExecutionPolicy(this.state, job).policyHash);
|
|
187
227
|
await this.reconcile(jobId, attempt?.command);
|
|
188
228
|
job.state = 'queued'; this.save(job); this.schedule();
|
|
189
229
|
} else throw new QueueError('Unknown action', 404);
|
package/factory/server.mjs
CHANGED
|
@@ -14,7 +14,7 @@ import { configAt, ROOT } from './lib.mjs';
|
|
|
14
14
|
import { VERSION } from './updates.mjs';
|
|
15
15
|
import { readProjectLinks } from './project-links.mjs';
|
|
16
16
|
import { attemptPresentation } from './execution-profile.mjs';
|
|
17
|
-
import { SourceAdmissionStore, publicSourceAdmission } from './source-admission.mjs';
|
|
17
|
+
import { SourceAdmissionStore, publicSourceAdmission, publicContinuation } from './source-admission.mjs';
|
|
18
18
|
import { deliveryProvider, deliveryProviderInfo } from './delivery-provider.mjs';
|
|
19
19
|
import { DeliveryService } from './delivery.mjs';
|
|
20
20
|
import { MAX_WEB_SCREENSHOT_BYTES } from './web-verification.mjs';
|
|
@@ -81,6 +81,7 @@ export function createController(state, adapter = executors(state), integrations
|
|
|
81
81
|
const authenticated = equal(request.headers.authorization, `Bearer ${token}`) || equal(request.headers['x-factory-session'], csrf);
|
|
82
82
|
if (request.method === 'GET' && url.pathname === '/api/v1/status') {
|
|
83
83
|
const jobs = queue.all().map(job => ({ ...job, source_admission: publicSourceAdmission(job.source_admission),
|
|
84
|
+
continuation: publicContinuation(job.continuation), continuation_status: queue.continuationStatus(job),
|
|
84
85
|
source_history: (job.source_history || []).map(publicSourceAdmission), can_request_changes: queue.canRequestChanges(job),
|
|
85
86
|
can_remove: queue.canRemove(job), removal_block_reason: queue.removalBlockReason(job),
|
|
86
87
|
delivery_removal_blocked: Boolean(job.delivery && !['published', 'abandoned'].includes(job.delivery.state)),
|