software-defence-factory 0.6.0 → 0.7.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 +17 -15
- package/docs/interfaces.md +1 -1
- package/docs/quickstart.md +6 -4
- package/docs/recovery.md +24 -4
- package/docs/workflows.md +11 -1
- package/factory/demo-fixture.mjs +11 -0
- package/factory/executor.mjs +18 -8
- package/factory/git-environment.mjs +26 -0
- package/factory/issue-provider.mjs +4 -2
- package/factory/lib.mjs +2 -2
- package/factory/processes.mjs +2 -1
- package/factory/project-links.mjs +3 -4
- package/factory/queue.mjs +56 -13
- package/factory/server.mjs +8 -4
- package/factory/source-admission.mjs +210 -0
- package/factory/ui/assets/{index-Whh9_Cb0.css → index-Bz6ECdy_.css} +1 -1
- package/factory/ui/assets/index-DqeOnrGK.js +13 -0
- package/factory/ui/index.html +2 -2
- package/package.json +2 -1
- package/scripts/probe-platform.mjs +58 -1
- package/scripts/probe-review.mjs +3 -2
- package/scripts/retained-source-fixture.mjs +55 -0
- package/factory/ui/assets/index-CdazRQSz.js +0 -13
|
@@ -14,6 +14,8 @@ import { admitIncident } from '../factory/incident.mjs';
|
|
|
14
14
|
import { DEFAULT_DEMO_STATE } from '../factory/paths.mjs';
|
|
15
15
|
import { bootstrap, registerInstallation, VERSION } from '../factory/updates.mjs';
|
|
16
16
|
import { hasService, manageService, serviceDefinition, withServiceOperation, isManagedLaunch } from '../factory/services.mjs';
|
|
17
|
+
import { runCandidateGit } from '../factory/git-environment.mjs';
|
|
18
|
+
import { initializeDemoRepository } from '../factory/demo-fixture.mjs';
|
|
17
19
|
|
|
18
20
|
try {
|
|
19
21
|
const handled = await bootstrap(process.argv.slice(2));
|
|
@@ -32,18 +34,18 @@ let state = resolve(flags.state || DEFAULT_STATE);
|
|
|
32
34
|
if(existsSync(state))state=realpathSync(state);
|
|
33
35
|
const alive = pid => { try { process.kill(pid,0); return true; } catch(error) { if(error.code === 'ESRCH')return false; throw error; } };
|
|
34
36
|
|
|
35
|
-
function init(repo, harness='codex', check='', port=7331) {
|
|
37
|
+
function init(repo, harness='codex', check='', port=7331, sourceRef='HEAD') {
|
|
36
38
|
repo=realpathSync(resolve(repo));
|
|
37
39
|
if (existsSync(join(state,'factory.json'))) throw new Error('Already configured; edit the private factory.json explicitly or choose another --state');
|
|
38
40
|
if ([repo,state,ROOT].some(p=>/[,\n\r]/.test(p))) throw new Error('Paths cannot contain commas or line breaks');
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
+
if (runCandidateGit(repo,'rev-parse','--show-toplevel') !== repo) throw new Error('--repo must be the Git root');
|
|
42
|
+
runCandidateGit(repo,'rev-parse','HEAD');
|
|
41
43
|
const presets={codex:['codex','exec','--json','--ephemeral','--sandbox','danger-full-access','-'],pi:['pi','--mode','json','--print','--no-session','--no-extensions','--skill','/factory-skills'],mock:['node','/opt/factory/mock.mjs']};
|
|
42
44
|
const argv=harness==='custom'?JSON.parse(flags['command-json'] || 'null'):presets[harness];
|
|
43
45
|
if (!argv) throw new Error('Select codex, pi, mock or custom with --command-json');
|
|
44
46
|
if (flags.model && ['codex','pi'].includes(harness)) argv.splice(harness==='codex'?argv.length-1:argv.length,0,'--model',flags.model);
|
|
45
47
|
mkdirSync(state,{recursive:true,mode:0o700});state=realpathSync(state);chmodSync(state,0o700);
|
|
46
|
-
save(join(state,'factory.json'),{version:1,repo,harness,command:argv,check,port:Number(port),image:PINS.jobImage,network:harness==='mock'?'none':'bridge',timeoutSeconds:1800,memoryMiB:2048,model:flags.model || null,
|
|
48
|
+
save(join(state,'factory.json'),{version:1,repo,sourceRef,harness,command:argv,check,port:Number(port),image:PINS.jobImage,network:harness==='mock'?'none':'bridge',timeoutSeconds:1800,memoryMiB:2048,model:flags.model || null,
|
|
47
49
|
scope:{project:'pilot',service:'app',environment:'test',owner:'operator'}});
|
|
48
50
|
configAt(state);
|
|
49
51
|
writeFileSync(join(state,'worker.token'),randomBytes(32).toString('hex')+'\n',{mode:0o600});
|
|
@@ -98,10 +100,10 @@ async function stop() {
|
|
|
98
100
|
}
|
|
99
101
|
stopContainers(state);console.log('Controller and its labelled containers stopped.');
|
|
100
102
|
}
|
|
101
|
-
async function submit(workflow,spec) {
|
|
103
|
+
async function submit(workflow,spec,sourceRef=flags['source-ref']) {
|
|
102
104
|
if(Buffer.byteLength(spec)>240000)throw new Error('Task exceeds 240 KB');
|
|
103
105
|
const title=workflow==='defence'?'Private incident triage':spec.split('\n').find(s=>s.trim())?.replace(/^#+\s*/, '').slice(0,100)||'Software task';
|
|
104
|
-
return api(state,'/api/v1/jobs',{workflow,repository:'app',spec,title});
|
|
106
|
+
return api(state,'/api/v1/jobs',{workflow,repository:'app',spec,title,...(sourceRef===undefined?{}:{source_ref:sourceRef})});
|
|
105
107
|
}
|
|
106
108
|
async function jobAction(action) {
|
|
107
109
|
const id=positional[0];if(!/^job_[a-z0-9]+$/.test(id || ''))throw new Error('A job ID is required');
|
|
@@ -118,12 +120,12 @@ async function jobAction(action) {
|
|
|
118
120
|
}
|
|
119
121
|
// The controller validates the current run and owns reconciliation atomically.
|
|
120
122
|
// A CLI-side stop after a stale snapshot could terminate a newer attempt.
|
|
121
|
-
await api(state,`/api/v1/jobs/${id}/${action}`,{run_id:current?.id,...(feedback===undefined?{}:{feedback})});
|
|
123
|
+
await api(state,`/api/v1/jobs/${id}/${action}`,{run_id:current?.id,...(feedback===undefined?{}:{feedback}),...(flags['source-ref']===undefined?{}:{source_ref:flags['source-ref']})});
|
|
122
124
|
console.log(`${action}: ${id}`);
|
|
123
125
|
}
|
|
124
126
|
|
|
125
127
|
try {
|
|
126
|
-
if(command==='init') { if(!flags.repo)throw new Error('init requires --repo /path/to/existing/git/repo');if(flags.harness && flags.agent && flags.harness !== flags.agent)throw new Error('--harness conflicts with legacy --agent');init(flags.repo,flags.harness || flags.agent,flags.check,flags.port); }
|
|
128
|
+
if(command==='init') { if(!flags.repo)throw new Error('init requires --repo /path/to/existing/git/repo');if(flags.harness && flags.agent && flags.harness !== flags.agent)throw new Error('--harness conflicts with legacy --agent');init(flags.repo,flags.harness || flags.agent,flags.check,flags.port,flags['source-ref'] || 'HEAD'); }
|
|
127
129
|
else if(command==='install')await withServiceOperation('install',install);
|
|
128
130
|
else if(command==='up') { if(hasService(state))await manageService('controller','start',state);else await withServiceOperation('up',up); }
|
|
129
131
|
else if(command==='stop') { if(hasService(state))await manageService('controller','stop',state);else await withServiceOperation('stop',stop); }
|
|
@@ -198,7 +200,7 @@ try {
|
|
|
198
200
|
input.title=flags.title || input.title;
|
|
199
201
|
if(typeof input.title!=='string'||!input.title.trim()||input.title.length>160)throw new Error('Provide a title of 1–160 characters (use --title for a blank issue)');
|
|
200
202
|
if(flags.workflow==='software'&&!configAt(state).check?.trim())throw new Error('Configure an app check before submitting software work');
|
|
201
|
-
console.log(JSON.stringify(await api(state,'/api/v1/jobs',{...input,workflow:flags.workflow,repository:'app',model:flags.model || ''}),null,2));
|
|
203
|
+
console.log(JSON.stringify(await api(state,'/api/v1/jobs',{...input,workflow:flags.workflow,repository:'app',model:flags.model || '',...(flags['source-ref']===undefined?{}:{source_ref:flags['source-ref']})}),null,2));
|
|
202
204
|
} else throw new Error('Use issue list|connection|templates|preview|recommend|draft|create|start|submissions|recover; see help');
|
|
203
205
|
} else if(command==='issues') {
|
|
204
206
|
console.log(JSON.stringify(await listIssues(configAt(state).repo,Number(flags.page || 1)),null,2));
|
|
@@ -225,9 +227,7 @@ try {
|
|
|
225
227
|
state=resolve(flags.state || DEFAULT_DEMO_STATE);
|
|
226
228
|
const repo=join(state,'sample-app');
|
|
227
229
|
if(!existsSync(join(state,'factory.json'))) {
|
|
228
|
-
|
|
229
|
-
writeFileSync(join(repo,'value.txt'),'broken\n');run('git',['-C',repo,'add','value.txt']);
|
|
230
|
-
run('git',['-C',repo,'-c','user.name=Factory demo','-c','user.email=demo@localhost','commit','-m','Synthetic fixture']);
|
|
230
|
+
initializeDemoRepository(repo);
|
|
231
231
|
init(repo,'mock',"test \"$(cat value.txt)\" = fixed",Number(flags.port || 7332));
|
|
232
232
|
} else if(harnessOf(configAt(state))!=='mock')throw new Error('Demo requires a mock configuration');
|
|
233
233
|
await withServiceOperation('demo startup',async()=>{await install();await up();});console.log(JSON.stringify(await submit('software','Synthetic installation qualification: fix value.txt. No inference is used.')));
|
|
@@ -243,7 +243,7 @@ try {
|
|
|
243
243
|
kit --output NEW_DIRECTORY Export the portable method without a runtime
|
|
244
244
|
demo Install and run a synthetic sample (no model key)
|
|
245
245
|
qualify --state PATH Exercise recovery and isolation with a stopped demo job
|
|
246
|
-
init --repo PATH --harness codex|pi|custom --check "npm ci && npm test"
|
|
246
|
+
init --repo PATH --harness codex|pi|custom --check "npm ci && npm test" [--source-ref REF]
|
|
247
247
|
install [--image LOCAL_REF] Build the standard image, or select an existing local image
|
|
248
248
|
doctor | up | status | stop Inspect / operate your private installation
|
|
249
249
|
foundation Read the operator setup skill; no installation required
|
|
@@ -272,15 +272,17 @@ try {
|
|
|
272
272
|
issue connection | submissions Inspect provider identity or durable creation receipts
|
|
273
273
|
issue recover --key REQUEST_ID Reconcile an uncertain creation without another write
|
|
274
274
|
issue start --draft draft.json | --url URL | --file brief.md --title TITLE
|
|
275
|
-
--workflow software|defence [--model MODEL]
|
|
275
|
+
--workflow software|defence [--source-ref REF] [--model MODEL]
|
|
276
276
|
Create a local issue and start work; no GitHub write
|
|
277
277
|
issues [--page N] Browse open project issues, with next_page for more
|
|
278
278
|
recommend --file task.md | --issue URL Suggest a work type without starting work
|
|
279
279
|
run --file task.md | --issue URL Submit software (default), or --workflow defence
|
|
280
|
+
[--source-ref REF] Pin a configured-repository ref before admission
|
|
280
281
|
incident --file incident.json Submit a private, read-only incident draft
|
|
281
282
|
approve JOB_ID | cancel JOB_ID Review gate / stop this attempt
|
|
282
283
|
retry JOB_ID Prove stop; retain old checkout and retry
|
|
283
|
-
revise JOB_ID --file feedback.md
|
|
284
|
+
revise JOB_ID --file feedback.md [--source-ref REF]
|
|
285
|
+
New build/check/review; source stays pinned unless a new ref is explicit
|
|
284
286
|
version Show the active CLI version
|
|
285
287
|
update | update --check Update the npm CLI / inspect the latest release
|
|
286
288
|
update --auto on|off Control automatic daily CLI updates
|
package/docs/interfaces.md
CHANGED
|
@@ -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 |
|
|
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 keep the recorded source unless a new ref is explicit; legacy source remains unknown |
|
|
36
36
|
| Trusted PR handoff | Operator applies accepted patch | Not implemented | Not implemented | #29; credentials remain outside jobs |
|
|
37
37
|
|
|
38
38
|
The current generic task form can name the Defence workflow; that is not a
|
package/docs/quickstart.md
CHANGED
|
@@ -23,12 +23,14 @@ The qualification intentionally creates failed, cancelled and interrupted tasks.
|
|
|
23
23
|
Commit an intentional, reviewed starting point in the application first. Jobs clone committed code only; uncommitted work stays in the source checkout.
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
software-defence-factory init --repo /absolute/path/to/app --harness codex --check "npm ci && npm test" --state /private/state/my-app --port 7331
|
|
26
|
+
software-defence-factory init --repo /absolute/path/to/app --harness codex --check "npm ci && npm test" --source-ref main --state /private/state/my-app --port 7331
|
|
27
27
|
software-defence-factory install --state /private/state/my-app
|
|
28
28
|
software-defence-factory doctor --state /private/state/my-app
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
`init --source-ref` selects the configured default ref (`HEAD` when omitted). Each job resolves that ref, or an explicit `--source-ref` on `run`/`issue start`, in the configured repository and durably retains its commit before acknowledging admission. The CLI and dashboard show the requested ref and resolved SHA. Task text and reference links do not select a repository or ref.
|
|
32
|
+
|
|
33
|
+
Verification commands receive `FACTORY_BASE_REVISION`, the resolved admission commit recorded as the candidate base. Diff-based checks should compare against this revision; the isolated checkout has no origin remote. The value comes from protected controller metadata, not the task text.
|
|
32
34
|
|
|
33
35
|
Replace the check with the application's actual verification command. `init` does not edit the app, copy global skills or start work. It creates factory.json, worker.token and model.env with private permissions. Each installation has one repository and a distinct state path/port. `--harness pi` selects Pi; `--harness custom --command-json '["executable","argument"]'` selects an available command in the job image. The bundled image provides Node, Git, Codex and Pi. Other toolchains require an intentionally built compatible image; do not claim Rust/mobile/browser capabilities from this image alone.
|
|
34
36
|
|
|
@@ -38,14 +40,14 @@ A local model endpoint must be reachable from inside the job container. Host loo
|
|
|
38
40
|
|
|
39
41
|
```sh
|
|
40
42
|
software-defence-factory up --state /private/state/my-app
|
|
41
|
-
software-defence-factory run --file task.md --state /private/state/my-app
|
|
43
|
+
software-defence-factory run --file task.md --source-ref main --state /private/state/my-app
|
|
42
44
|
```
|
|
43
45
|
|
|
44
46
|
A task should describe the accepted outcome, allowed scope and observable checks. The CLI also accepts `--issue https://github.com/owner/repo/issues/123` for an issue belonging to the configured origin; it uses the operator's existing gh access outside the job. The dashboard supports the same task workflow. Source text and links do not grant additional authority.
|
|
45
47
|
|
|
46
48
|
## Review and handoff
|
|
47
49
|
|
|
48
|
-
Inspect the task's Result, Files and History tabs. Build evidence includes candidate.json, change.patch and the implementation report. Checks and review identify their exact commit and policy hash. Approval revalidates both before writing accepted.json. Request changes
|
|
50
|
+
Inspect the task's Result, Files and History tabs. Task details show the requested source ref, resolved admission SHA and previous source commits when a new base was selected. Build evidence includes candidate.json, change.patch and the implementation report; handoff records its source SHA. Checks and review identify their exact candidate commit and policy hash. Approval revalidates both before writing accepted.json. Request changes preserves the recorded source by default and starts fresh checks/review; an explicit new source ref is retained as a deliberate base change.
|
|
49
51
|
|
|
50
52
|
The source application is not changed and no branch, PR, merge or deployment is published automatically. A reviewed change.patch can be checked and applied with `git apply --check` and `git apply` on an appropriate branch at its recorded base revision; then follow the application's normal integrated checks and delivery policy.
|
|
51
53
|
|
package/docs/recovery.md
CHANGED
|
@@ -4,9 +4,10 @@ Use `status --state PATH` and the private supervisor.log to identify the active
|
|
|
4
4
|
|
|
5
5
|
- A normal `stop` signals the controller, waits for the executor process group, removes its labelled containers and retains the database and artifacts.
|
|
6
6
|
- An unconfirmed running attempt becomes `interrupted` on controller restart. It is never silently considered successful.
|
|
7
|
-
-
|
|
7
|
+
- Admission resolves the configured source ref or an explicit `--source-ref` in the configured repository, records its identity/ref/SHA in private job metadata and retains its Git objects under that job. Build and retry restore from this retained revision; moving or deleting the source ref does not select a new commit.
|
|
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-*.
|
|
8
9
|
- A failed verification can retry the same unchanged candidate after the check environment is repaired. A changed candidate needs a fresh verification/review sequence.
|
|
9
|
-
- A requested revision retains previous evidence and starts a new build
|
|
10
|
+
- A requested revision keeps the recorded source by default, retains previous evidence and starts a new build with accumulated feedback. 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.
|
|
10
11
|
- 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.
|
|
11
12
|
|
|
12
13
|
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.
|
|
@@ -34,11 +35,14 @@ software-defence-factory revise JOB_ID --file /private/revision.md --state /priv
|
|
|
34
35
|
|
|
35
36
|
Feedback must be nonempty and at most 4,000 characters. It is accumulated in the
|
|
36
37
|
bounded job prompt. The controller checks the latest attempt and reconciles its
|
|
37
|
-
processes before starting a new build from the
|
|
38
|
+
processes before starting a new build from the retained source commit with that
|
|
38
39
|
feedback. The old checkout moves to `previous-checkout-*`; the old failed review
|
|
39
40
|
and reports remain intact. Verification, review and operator approval are all
|
|
40
41
|
required again. A stale or duplicate request cannot approve or restart a newer
|
|
41
|
-
attempt.
|
|
42
|
+
attempt. To deliberately change the base, pass `--source-ref REF`; the controller
|
|
43
|
+
resolves and retains it from the same configured repository before changing the
|
|
44
|
+
job record. If the request cannot reconcile, its unlinked source copy is removed.
|
|
45
|
+
**Retry** only repeats the stopped phase and is suitable for a repaired
|
|
42
46
|
execution environment; retrying review cannot change the candidate.
|
|
43
47
|
|
|
44
48
|
A crashed review without a validated verdict cannot request implementation
|
|
@@ -91,6 +95,22 @@ facts in a separate private incident note with evidence paths and unknowns; leav
|
|
|
91
95
|
the original queue/evidence unchanged. Without that evidence, model/image facts
|
|
92
96
|
cannot be reconstructed reliably.
|
|
93
97
|
|
|
98
|
+
### Legacy source records
|
|
99
|
+
|
|
100
|
+
Jobs admitted before source retention keep their historical candidate and review
|
|
101
|
+
evidence without an invented admission-time SHA. A complete existing candidate
|
|
102
|
+
can still pass the normal current candidate, policy, review and approval guards;
|
|
103
|
+
the handoff labels its source **Not recorded (legacy/unknown)**. An unpinned
|
|
104
|
+
queued job is blocked on controller restart, and an unpinned job cannot retry or
|
|
105
|
+
request implementation changes. Submit a replacement job to capture the
|
|
106
|
+
configured or explicit source ref before execution. Do not reconstruct proof
|
|
107
|
+
from the current checkout, issue text or a later ref value.
|
|
108
|
+
|
|
109
|
+
If a retained store is missing or corrupt, retry and revision stop with an
|
|
110
|
+
explicit source error. Restore the private state backup containing that job's
|
|
111
|
+
retained Git objects, or submit a replacement from a source ref that still
|
|
112
|
+
resolves. The controller never substitutes the mutable configured checkout.
|
|
113
|
+
|
|
94
114
|
### Interrupted image selection or controller startup
|
|
95
115
|
|
|
96
116
|
`installation.lock` serializes image changes with controller startup, including
|
package/docs/workflows.md
CHANGED
|
@@ -39,6 +39,12 @@ Conflicting labels request a choice. This is a simple editable suggestion, not a
|
|
|
39
39
|
model assessment or permission to act. A security code fix can remain Software.
|
|
40
40
|
Both sources support either type; Defence still produces a private draft.
|
|
41
41
|
|
|
42
|
+
The **Source ref** option applies to the configured local Git repository. Leave
|
|
43
|
+
it blank to use the configured default ref, or enter a branch, tag, `HEAD` or
|
|
44
|
+
full commit SHA. The controller resolves it and retains the commit before
|
|
45
|
+
admitting the job; the task and any linked issue cannot select another
|
|
46
|
+
repository. Status and task details show the requested ref and resolved SHA.
|
|
47
|
+
|
|
42
48
|
Repository templates are read from `.github/ISSUE_TEMPLATE` on the default
|
|
43
49
|
branch through GitHub's API. Markdown templates and YAML markdown, input,
|
|
44
50
|
textarea, dropdown (including multiple choices) and checkboxes are supported.
|
|
@@ -75,7 +81,7 @@ software-defence-factory issue submissions --state PATH
|
|
|
75
81
|
software-defence-factory issue recover --state PATH --key release-board-fix-01
|
|
76
82
|
# Explicit execution, independent of creation:
|
|
77
83
|
software-defence-factory issue start --state PATH --url URL --workflow software
|
|
78
|
-
software-defence-factory issue start --state PATH --file brief.md --title "Investigate supplied evidence" --workflow defence
|
|
84
|
+
software-defence-factory issue start --state PATH --file brief.md --title "Investigate supplied evidence" --workflow defence --source-ref main
|
|
79
85
|
```
|
|
80
86
|
|
|
81
87
|
`answers.json` contains `{"title":"Fix the board","answers":{"problem":"..."}}`;
|
|
@@ -84,6 +90,10 @@ arrays of exact option labels. `issue create` now publishes only; migrate 0.5.1
|
|
|
84
90
|
execution scripts to `issue start`. Legacy `run` remains compatible. Typed private
|
|
85
91
|
incident admission remains `incident --file`, distinct from a generic Defence brief.
|
|
86
92
|
`--source github` remains an alias for repository listing; `--github URL` remains a compatibility alias for `--url URL`.
|
|
93
|
+
`run` and `issue start` accept the same `--source-ref`; `init --source-ref` sets
|
|
94
|
+
the configured default. A revision keeps the admitted SHA unless an explicit
|
|
95
|
+
new ref is supplied. Every revision gets a fresh implementation, check and
|
|
96
|
+
review sequence while earlier source records and evidence remain available.
|
|
87
97
|
|
|
88
98
|
Provider selection and unknown-host behavior are documented in [integrations](integrations.md).
|
|
89
99
|
View repo uses the browser's own login. Factory's provider uses the controller
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { runCandidateGit } from './git-environment.mjs';
|
|
4
|
+
|
|
5
|
+
export function initializeDemoRepository(repo) {
|
|
6
|
+
mkdirSync(repo, { recursive: true, mode: 0o700 });
|
|
7
|
+
runCandidateGit(repo, 'init', '-b', 'main');
|
|
8
|
+
writeFileSync(join(repo, 'value.txt'), 'broken\n');
|
|
9
|
+
runCandidateGit(repo, 'add', 'value.txt');
|
|
10
|
+
runCandidateGit(repo, '-c', 'user.name=Factory demo', '-c', 'user.email=demo@localhost', 'commit', '-m', 'Synthetic fixture');
|
|
11
|
+
}
|
package/factory/executor.mjs
CHANGED
|
@@ -6,6 +6,8 @@ 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, restoreRetainedCheckout } from './source-admission.mjs';
|
|
10
|
+
import { runCandidateGit } from './git-environment.mjs';
|
|
9
11
|
|
|
10
12
|
const [state, phase] = process.argv.slice(2);
|
|
11
13
|
if(process.getuid()===0)throw new Error('Agent jobs require a non-root controller account');
|
|
@@ -15,6 +17,9 @@ if (!['build','verify','review','handoff','defence'].includes(phase)) throw new
|
|
|
15
17
|
const folder = join(state, 'jobs', job), workspace = join(folder, 'checkout');
|
|
16
18
|
const config = json(join(folder, attempt, 'execution-config.json'));
|
|
17
19
|
const execution = json(join(folder, 'artifacts', attempt, 'execution.json'));
|
|
20
|
+
let sourceAdmission;
|
|
21
|
+
try { sourceAdmission = JSON.parse(process.env.SDF_SOURCE_ADMISSION || 'null'); }
|
|
22
|
+
catch { throw new Error('Protected source admission metadata is malformed'); }
|
|
18
23
|
const policyHash = digest(JSON.stringify(config));
|
|
19
24
|
if (execution.policyHash !== policyHash || execution.phase !== phase) throw new Error('Admitted execution profile does not match this attempt');
|
|
20
25
|
const output = process.env.SDF_OUTPUT_DIR, result = process.env.SDF_STEP_RESULT_PATH;
|
|
@@ -29,12 +34,13 @@ for await (const part of process.stdin) {
|
|
|
29
34
|
prompt += part;
|
|
30
35
|
if (Buffer.byteLength(prompt) > 256000) throw new Error('Task brief too large');
|
|
31
36
|
}
|
|
32
|
-
const
|
|
33
|
-
const git = (...args) => run('git', ['-c','core.hooksPath=/dev/null','-c','core.fsmonitor=false','-C',workspace,...args], { env: gitEnv });
|
|
37
|
+
const git = (...args) => runCandidateGit(workspace, ...args);
|
|
34
38
|
const metadata = () => json(join(folder, 'candidate.json'));
|
|
35
39
|
function candidate() {
|
|
36
40
|
const meta = metadata();
|
|
37
41
|
if (git('rev-parse','HEAD') !== meta.head || git('status','--porcelain')) throw new Error('Candidate changed; create a new task and rerun verification');
|
|
42
|
+
if (sourceAdmission?.status === 'retained' && (meta.base !== sourceAdmission.resolved_sha || meta.source_admission?.resolved_sha !== sourceAdmission.resolved_sha))
|
|
43
|
+
throw new Error('Candidate does not use this job’s retained source revision; previous evidence is invalid for the current base');
|
|
38
44
|
return meta;
|
|
39
45
|
}
|
|
40
46
|
function safeRead(path) {
|
|
@@ -108,9 +114,12 @@ try {
|
|
|
108
114
|
if(incident)prompt=JSON.stringify(incident.input);
|
|
109
115
|
if (phase === 'build' || phase === 'defence') {
|
|
110
116
|
if (existsSync(workspace)) throw new Error('Workspace already exists; preserve evidence and create a new task for a fresh build');
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
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.');
|
|
118
|
+
const retained = assertRetainedSource(state, job, sourceAdmission);
|
|
119
|
+
restoreRetainedCheckout(state, job, sourceAdmission, workspace);
|
|
120
|
+
const head = git('rev-parse','HEAD');
|
|
121
|
+
if (head !== retained.sha) throw new Error('Build checkout differs from its admission-time source revision');
|
|
122
|
+
save(join(folder,'candidate.json'), { base: sourceAdmission.resolved_sha, head, source_admission: publicSourceAdmission(sourceAdmission), synthetic: harnessOf(config) === 'mock' });
|
|
114
123
|
}
|
|
115
124
|
if (phase === 'build') {
|
|
116
125
|
const reports = await container('build', brief('Implement the requested bounded change. Save /output/agent-report.md with actual changes and remaining uncertainty.'), config.command, true, true);
|
|
@@ -118,7 +127,7 @@ try {
|
|
|
118
127
|
if (git('diff','--cached','--stat')) git('-c','user.name=Arcitai Factory','-c','user.email=factory@localhost','commit','--no-verify','-m','Factory candidate');
|
|
119
128
|
// Checks must cover the committed tree, not ignored build products supplied by the agent.
|
|
120
129
|
git('clean','-fdx');
|
|
121
|
-
const meta = { ...metadata(), head: git('rev-parse','HEAD') };
|
|
130
|
+
const meta = { ...metadata(), head: git('rev-parse','HEAD'), source_admission: publicSourceAdmission(sourceAdmission) };
|
|
122
131
|
save(join(folder,'candidate.json'),meta);
|
|
123
132
|
save(join(output,'candidate.json'),meta);
|
|
124
133
|
writeFileSync(join(output,'change.patch'), git('diff','--binary',meta.base,meta.head) + '\n');
|
|
@@ -146,8 +155,9 @@ try {
|
|
|
146
155
|
} else if (phase === 'handoff') {
|
|
147
156
|
const meta = candidate(), review = json(join(folder,'review.json')), checks = json(join(folder,'checks.json'));
|
|
148
157
|
if (review.verdict !== 'pass' || review.head !== meta.head || !checks.passed || checks.head !== meta.head || checks.policyHash!==policyHash || review.policyHash!==policyHash) throw new Error('Review/checks do not cover candidate and current policy');
|
|
149
|
-
|
|
150
|
-
|
|
158
|
+
const sourceSha = meta.source_admission?.resolved_sha || 'Not recorded (legacy/unknown)';
|
|
159
|
+
writeFileSync(join(output,'handoff.md'), `Ready for manual handoff at ${meta.head}, based on source ${sourceSha}.\nNo PR, merge or deployment performed.\nSee docs/quickstart.md for applying the reviewed change.patch to your own branch.\n`);
|
|
160
|
+
save(join(folder,'accepted.json'), { head: meta.head, source_admission: meta.source_admission || publicSourceAdmission(null), acceptedAt: new Date().toISOString() });
|
|
151
161
|
} else if (phase === 'defence') {
|
|
152
162
|
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,false,true);
|
|
153
163
|
const report = JSON.parse(safeRead(join(reports,'incident-report.json')));
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { run } from './lib.mjs';
|
|
2
|
+
|
|
3
|
+
// Git reads repository and configuration overrides from its environment before
|
|
4
|
+
// considering -C. Host operations must therefore start from a bounded context
|
|
5
|
+
// rather than inherit the controller's arbitrary GIT_* variables.
|
|
6
|
+
export function hostGitEnvironment(inherited = process.env) {
|
|
7
|
+
const env = { ...inherited };
|
|
8
|
+
for (const key of Object.keys(env)) if (/^GIT_/i.test(key)) delete env[key];
|
|
9
|
+
env.GIT_CONFIG_NOSYSTEM = '1';
|
|
10
|
+
env.GIT_CONFIG_GLOBAL = '/dev/null';
|
|
11
|
+
env.GIT_TERMINAL_PROMPT = '0';
|
|
12
|
+
return env;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function runHostGit(args, options = {}) {
|
|
16
|
+
return run('git', args, { ...options, env: hostGitEnvironment() });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runCandidateGit(workspace, ...args) {
|
|
20
|
+
return runHostGit([
|
|
21
|
+
'-c', 'core.hooksPath=/dev/null',
|
|
22
|
+
'-c', 'core.fsmonitor=false',
|
|
23
|
+
'-C', workspace,
|
|
24
|
+
...args,
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { execFileSync } from 'node:child_process';
|
|
2
1
|
import { githubIssueProvider } from './providers/github.mjs';
|
|
3
2
|
import { readProjectLinks } from './project-links.mjs';
|
|
4
3
|
import { QueueError } from './queue.mjs';
|
|
4
|
+
import { runHostGit } from './git-environment.mjs';
|
|
5
5
|
|
|
6
6
|
// Selection is local and capability-based. Unknown hosts never receive a GitHub
|
|
7
7
|
// token or a guessed API request. Git checkout/execution does not need an adapter.
|
|
@@ -9,7 +9,9 @@ export function issueProvider(repo) {
|
|
|
9
9
|
if (readProjectLinks(repo)) return githubIssueProvider(repo);
|
|
10
10
|
let host = null;
|
|
11
11
|
try {
|
|
12
|
-
const remote =
|
|
12
|
+
const remote = runHostGit(['-c', 'core.fsmonitor=false', '-C', repo, 'config', '--local', '--get', 'remote.origin.url'], {
|
|
13
|
+
timeout: 2000, maxBuffer: 4096, stdio: ['ignore', 'pipe', 'ignore'],
|
|
14
|
+
});
|
|
13
15
|
if (/^(https?|ssh):\/\//.test(remote)) host = new URL(remote).hostname;
|
|
14
16
|
else host = remote.match(/^(?:[^@\s]+@)?([a-zA-Z0-9.-]+):[^/]/)?.[1] || null;
|
|
15
17
|
if (!/^[a-zA-Z0-9.-]+$/.test(host || '')) host = null;
|
package/factory/lib.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
|
|
2
2
|
import { resolve, dirname, join, isAbsolute } from 'node:path';
|
|
3
3
|
import { spawnSync, spawn } from 'node:child_process';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
@@ -46,8 +46,8 @@ export function configAt(state) {
|
|
|
46
46
|
if (!['none','bridge'].includes(config.network)) throw new Error('network must be none or bridge; host networking is not supported');
|
|
47
47
|
if (typeof config.image !== 'string' || !/^[a-zA-Z0-9][a-zA-Z0-9_./:@-]*$/.test(config.image)) throw new Error('Invalid container image');
|
|
48
48
|
if (typeof config.repo !== 'string' || !isAbsolute(config.repo) || [config.repo,state,ROOT].some(p=>/[,\n\r]/.test(p))) throw new Error('Expected absolute paths without commas or line breaks');
|
|
49
|
+
if (config.sourceRef !== undefined && (typeof config.sourceRef !== 'string' || !config.sourceRef || Buffer.byteLength(config.sourceRef) > 256 || /[\u0000-\u001f\u007f]/.test(config.sourceRef))) throw new Error('sourceRef must be a Git ref under 256 bytes');
|
|
49
50
|
if (typeof config.check !== 'string' || !config.scope || !['project','service','environment','owner'].every(k=>typeof config.scope[k]==='string'&&config.scope[k].trim())) throw new Error('Missing check or installation scope');
|
|
50
|
-
if (!existsSync(config.repo)) throw new Error('Configured repository is missing');
|
|
51
51
|
return config;
|
|
52
52
|
}
|
|
53
53
|
export async function api(state, path, body, method) {
|
package/factory/processes.mjs
CHANGED
|
@@ -125,7 +125,8 @@ export function executors(state) {
|
|
|
125
125
|
mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
126
126
|
const resultPath = join(output, 'result.json'), fd = openSync(join(output, 'executor.log'), 'a', 0o600);
|
|
127
127
|
const child = spawn(process.execPath, [join(ROOT, 'factory/executor.mjs'), state, attempt.command], {
|
|
128
|
-
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
|
|
128
|
+
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,
|
|
129
|
+
SDF_SOURCE_ADMISSION: JSON.stringify(job.source_admission || null) },
|
|
129
130
|
}); closeSync(fd);
|
|
130
131
|
let finish;
|
|
131
132
|
const done = new Promise(resolve => { finish = resolve; });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { runHostGit } from './git-environment.mjs';
|
|
2
2
|
|
|
3
3
|
// Only a canonical public web URL is exposed. Git transports may contain
|
|
4
4
|
// credentials; never return the original remote or derive an owner from a path.
|
|
@@ -11,9 +11,8 @@ export function githubProjectLinks(remote) {
|
|
|
11
11
|
}
|
|
12
12
|
export function readProjectLinks(repo) {
|
|
13
13
|
try {
|
|
14
|
-
const remote =
|
|
15
|
-
|
|
16
|
-
env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' },
|
|
14
|
+
const remote = runHostGit(['-c', 'core.fsmonitor=false', '-C', repo, 'config', '--local', '--get', 'remote.origin.url'], {
|
|
15
|
+
timeout: 2000, maxBuffer: 4096, stdio: ['ignore', 'pipe', 'ignore'],
|
|
17
16
|
});
|
|
18
17
|
return githubProjectLinks(remote);
|
|
19
18
|
} catch { return undefined; }
|
package/factory/queue.mjs
CHANGED
|
@@ -12,16 +12,22 @@ export class QueueError extends Error { constructor(message, status = 409) { sup
|
|
|
12
12
|
// One controller owns this database and one executor at a time. Each transition
|
|
13
13
|
// is committed before execution starts; a restart never assumes a result.
|
|
14
14
|
export class JobQueue {
|
|
15
|
-
constructor(state, { execute, stop, reconcile, prepare, reviewVerdict = () => undefined }) {
|
|
15
|
+
constructor(state, { execute, stop, reconcile, prepare, reviewVerdict = () => undefined, sourceAdmission }) {
|
|
16
16
|
this.db = new DatabaseSync(join(state, 'jobs.sqlite'));
|
|
17
17
|
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
|
-
this.execute = execute; this.stop = stop; this.reconcile = reconcile; this.prepare = prepare; this.reviewVerdict = reviewVerdict;
|
|
18
|
+
this.execute = execute; this.stop = stop; this.reconcile = reconcile; this.prepare = prepare; this.reviewVerdict = reviewVerdict; this.sourceAdmission = sourceAdmission;
|
|
19
19
|
this.maintenanceFile = join(state, 'maintenance.json');
|
|
20
20
|
this.active = null; this.closing = false; this.pumping = false; this.actions = new Set(); this.maintenance = existsSync(this.maintenanceFile);
|
|
21
|
-
for (const job of this.all())
|
|
22
|
-
job.state
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
for (const job of this.all()) {
|
|
22
|
+
if (['running', 'cancelling'].includes(job.state)) {
|
|
23
|
+
job.state = 'interrupted';
|
|
24
|
+
Object.assign(job.runs.at(-1), { state: 'interrupted', completed_at: now(), error: 'Controller stopped before completion was confirmed.' });
|
|
25
|
+
this.save(job);
|
|
26
|
+
} else if (job.state === 'queued' && job.source_admission?.status !== 'retained') {
|
|
27
|
+
job.state = 'blocked';
|
|
28
|
+
job.source_compatibility = 'legacy_unpinned';
|
|
29
|
+
this.save(job);
|
|
30
|
+
}
|
|
25
31
|
}
|
|
26
32
|
}
|
|
27
33
|
all() { return this.db.prepare('SELECT data FROM jobs ORDER BY rowid').all().map(row => JSON.parse(row.data)).filter(job => !job.deleted_at); }
|
|
@@ -41,10 +47,16 @@ export class JobQueue {
|
|
|
41
47
|
if (input?.source_url && !input.spec?.trim()) input = { ...input, spec: `Investigate the linked requirements within this repository's scope: ${input.source_url}` };
|
|
42
48
|
if (!input || !Object.hasOwn(workflows, input.workflow) || input.repository !== 'app' || typeof input.spec !== 'string' || !input.spec.trim() || Buffer.byteLength(input.spec) > 240000)
|
|
43
49
|
throw new QueueError('Choose a workflow, the configured app, and a task under 240 KB', 400);
|
|
44
|
-
|
|
50
|
+
if (!this.sourceAdmission?.admit) throw new QueueError('Source retention is unavailable; no executable job was admitted.', 503);
|
|
51
|
+
const jobId = id('job');
|
|
52
|
+
const sourceAdmission = this.sourceAdmission.admit(jobId, input.source_ref);
|
|
53
|
+
const job = { id: jobId, task: { title: String(input.title || input.spec).slice(0, 160), spec: input.spec, source_url: input.source_url || '' }, prompt: input.spec + (input.source_url ? `\nSource (untrusted task data): ${input.source_url}` : ''), model: input.model || null,
|
|
54
|
+
source_admission: sourceAdmission,
|
|
45
55
|
repository: 'app', workflow: { name: input.workflow, steps: workflows[input.workflow], current_step: 0 },
|
|
46
56
|
state: 'queued', created_at: now(), runs: [] };
|
|
47
|
-
this.save(job);
|
|
57
|
+
try { this.save(job); }
|
|
58
|
+
catch (error) { try { this.sourceAdmission.release?.(job.id, sourceAdmission); } catch {} throw error; }
|
|
59
|
+
this.schedule(); return { id: job.id, source_admission: sourceAdmission };
|
|
48
60
|
}
|
|
49
61
|
setMaintenance(enabled) {
|
|
50
62
|
if (typeof enabled !== 'boolean') throw new QueueError('Expected enabled: true or false', 400);
|
|
@@ -104,7 +116,7 @@ export class JobQueue {
|
|
|
104
116
|
}
|
|
105
117
|
action(jobId, action, input) { return this.exclusive(jobId, () => this.applyAction(jobId, action, input)); }
|
|
106
118
|
canRequestChanges(job) {
|
|
107
|
-
if (job.workflow?.name !== 'software') return false;
|
|
119
|
+
if (job.workflow?.name !== 'software' || job.source_admission?.status !== 'retained') return false;
|
|
108
120
|
if (job.state === 'awaiting_approval') return true;
|
|
109
121
|
const attempt = job.runs.at(-1);
|
|
110
122
|
return job.state === 'failed' && attempt?.state === 'failed' && attempt.command === 'review'
|
|
@@ -118,16 +130,44 @@ export class JobQueue {
|
|
|
118
130
|
if (job.state !== 'awaiting_approval') throw new QueueError('Job is not awaiting approval');
|
|
119
131
|
job.state = 'queued'; attempt.state = 'queued'; this.save(job); this.schedule();
|
|
120
132
|
} else if (action === 'request_changes') {
|
|
121
|
-
if (!this.canRequestChanges(job)) throw new QueueError(
|
|
133
|
+
if (!this.canRequestChanges(job)) throw new QueueError(job.source_admission?.status === 'retained'
|
|
134
|
+
? 'Only a reviewed software task can be revised'
|
|
135
|
+
: 'This legacy job has no admission-time source revision. Submit a replacement job to start from an explicitly recorded source.');
|
|
122
136
|
if (typeof input.feedback !== 'string' || !input.feedback.trim() || input.feedback.length > 4000) throw new QueueError('Provide revision feedback under 4000 characters', 400);
|
|
123
137
|
const revisedPrompt = job.prompt + `\n\nRequested revision: ${input.feedback}`;
|
|
124
138
|
if (Buffer.byteLength(revisedPrompt) > 240000) throw new QueueError('Accumulated revision instructions exceed 240 KB; create a bounded continuation task', 400);
|
|
125
|
-
|
|
139
|
+
const explicitSource = input.source_ref !== undefined;
|
|
140
|
+
if (typeof this.sourceAdmission?.validate !== 'function') throw new QueueError('Retained source validation is unavailable; this job was not revised.', 503);
|
|
141
|
+
const admittedSource = explicitSource
|
|
142
|
+
? this.sourceAdmission.admit(job.id, input.source_ref, job.source_admission.repository_identity)
|
|
143
|
+
: job.source_admission;
|
|
144
|
+
if (!explicitSource) this.sourceAdmission.validate(job.id, job.source_admission);
|
|
145
|
+
const changedBase = admittedSource.resolved_sha !== job.source_admission.resolved_sha;
|
|
146
|
+
let repairRetainedSource = false;
|
|
147
|
+
if (explicitSource && !changedBase) {
|
|
148
|
+
try { this.sourceAdmission.validate(job.id, job.source_admission); }
|
|
149
|
+
catch { repairRetainedSource = true; }
|
|
150
|
+
}
|
|
151
|
+
const replaceSourceRecord = changedBase || repairRetainedSource;
|
|
152
|
+
const nextSource = replaceSourceRecord ? admittedSource : job.source_admission;
|
|
153
|
+
try { await this.reconcile(jobId, 'build'); }
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (explicitSource) this.sourceAdmission.release?.(job.id, admittedSource);
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
if (explicitSource && !replaceSourceRecord) this.sourceAdmission.release?.(job.id, admittedSource);
|
|
126
159
|
// A failed review stays failed. Revision feedback must not rewrite its result.
|
|
127
|
-
attempt.revision = { feedback: input.feedback, requested_at: now() };
|
|
160
|
+
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 } : {}) };
|
|
128
161
|
if (job.state === 'awaiting_approval') Object.assign(attempt, { state: 'succeeded', outcome: 'changes_requested', summary: input.feedback, completed_at: now(), duration_millis: 0 });
|
|
162
|
+
if (replaceSourceRecord) {
|
|
163
|
+
job.source_history = [...(job.source_history || []), job.source_admission];
|
|
164
|
+
job.source_admission = nextSource;
|
|
165
|
+
}
|
|
129
166
|
job.prompt = revisedPrompt;
|
|
130
|
-
job.workflow.current_step = 0; job.state = 'queued';
|
|
167
|
+
job.workflow.current_step = 0; job.state = 'queued';
|
|
168
|
+
try { this.save(job); }
|
|
169
|
+
catch (error) { if (replaceSourceRecord) this.sourceAdmission.release?.(job.id, admittedSource); throw error; }
|
|
170
|
+
this.schedule();
|
|
131
171
|
} else if (action === 'cancel') {
|
|
132
172
|
if (!['queued', 'running', 'awaiting_approval', 'blocked', 'interrupted'].includes(job.state)) throw new QueueError('Job is already stopped');
|
|
133
173
|
job.state = 'cancelling'; this.save(job);
|
|
@@ -138,7 +178,10 @@ export class JobQueue {
|
|
|
138
178
|
if (attempt) Object.assign(attempt, { state: 'cancelled', completed_at: now() });
|
|
139
179
|
this.save(job);
|
|
140
180
|
} else if (action === 'retry') {
|
|
181
|
+
if (job.source_admission?.status !== 'retained') throw new QueueError('This legacy job has no admission-time source revision and cannot be retried. Submit a replacement job to capture a source revision explicitly.');
|
|
141
182
|
if (!['failed', 'interrupted', 'cancelled'].includes(job.state)) throw new QueueError('Only a stopped attempt can be retried');
|
|
183
|
+
if (typeof this.sourceAdmission?.validate !== 'function') throw new QueueError('Retained source validation is unavailable; this job was not retried.', 503);
|
|
184
|
+
this.sourceAdmission.validate(job.id, job.source_admission);
|
|
142
185
|
await this.reconcile(jobId, attempt?.command);
|
|
143
186
|
job.state = 'queued'; this.save(job); this.schedule();
|
|
144
187
|
} else throw new QueueError('Unknown action', 404);
|
package/factory/server.mjs
CHANGED
|
@@ -14,6 +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
18
|
|
|
18
19
|
function equal(a, b) { return typeof a === 'string' && Buffer.byteLength(a) === Buffer.byteLength(b) && timingSafeEqual(Buffer.from(a), Buffer.from(b)); }
|
|
19
20
|
async function body(request) {
|
|
@@ -33,7 +34,8 @@ function artifacts(state, jobId) {
|
|
|
33
34
|
export function createController(state, adapter = executors(state), integrations = {}) {
|
|
34
35
|
const config = configAt(state), csrf = randomBytes(32).toString('hex');
|
|
35
36
|
const token = readFileSync(join(state, 'worker.token'), 'utf8').trim();
|
|
36
|
-
const
|
|
37
|
+
const sourceAdmission = new SourceAdmissionStore(state, config.repo, config.sourceRef || 'HEAD');
|
|
38
|
+
const queue = new JobQueue(state, { ...adapter, sourceAdmission });
|
|
37
39
|
const provider = integrations.issueProvider || issueProvider(config.repo);
|
|
38
40
|
const submissions = new IssueSubmissions(queue, provider);
|
|
39
41
|
const projectLinks = readProjectLinks(config.repo);
|
|
@@ -50,13 +52,14 @@ export function createController(state, adapter = executors(state), integrations
|
|
|
50
52
|
const url = new URL(request.url, `http://${request.headers.host}`);
|
|
51
53
|
const authenticated = equal(request.headers.authorization, `Bearer ${token}`) || equal(request.headers['x-factory-session'], csrf);
|
|
52
54
|
if (request.method === 'GET' && url.pathname === '/api/v1/status') {
|
|
53
|
-
const jobs = queue.all().map(job => ({ ...job,
|
|
55
|
+
const jobs = queue.all().map(job => ({ ...job, source_admission: publicSourceAdmission(job.source_admission),
|
|
56
|
+
source_history: (job.source_history || []).map(publicSourceAdmission), can_request_changes: queue.canRequestChanges(job), runs: job.runs.map(attempt => attemptPresentation({ ...attempt,
|
|
54
57
|
outcome: attempt.outcome || (attempt.state === 'succeeded' ? 'complete' : undefined) }, adapter.usage?.(job, attempt))) }));
|
|
55
58
|
return send(200, { version: 1, runtime_version: VERSION, maintenance: queue.maintenance, workflows: Object.keys(definitions.workflows), commands: [], triggers: [], jobs, csrf_token: csrf,
|
|
56
59
|
infrastructure: { host, controller: { connected: !queue.closing }, workers: [{ id: 'local-executor', name: 'Local worker', host: host.hostname, connected: !queue.closing }] },
|
|
57
60
|
automations: [], automation_control: definitions.automations, issue_provider: providerInfo(provider),
|
|
58
61
|
workers: [{ name: 'Local worker', machine: host, instance_id: 'local-executor', repositories: ['app'], connected: !queue.closing, last_seen_at: new Date().toISOString() }], // v1 compatibility view
|
|
59
|
-
repositories: ['app'], repo: config.repo, project_links: projectLinks, harness: harnessOf(config), agent: harnessOf(config) });
|
|
62
|
+
repositories: ['app'], repo: config.repo, project_links: projectLinks, source_ref_default: config.sourceRef || 'HEAD', harness: harnessOf(config), agent: harnessOf(config) });
|
|
60
63
|
}
|
|
61
64
|
if (request.method === 'GET' && url.pathname === '/api/v1/definitions') {
|
|
62
65
|
return send(200, definitions);
|
|
@@ -112,7 +115,8 @@ export function createController(state, adapter = executors(state), integrations
|
|
|
112
115
|
}
|
|
113
116
|
if (url.pathname === '/api/v1/jobs') {
|
|
114
117
|
if (input.model && input.model !== config.model && !['codex','pi'].includes(harnessOf(config))) throw new QueueError('Model overrides require a codex or pi executor', 400);
|
|
115
|
-
|
|
118
|
+
const created = queue.submit(input);
|
|
119
|
+
return send(201, { id: created.id, source_admission: publicSourceAdmission(created.source_admission) });
|
|
116
120
|
}
|
|
117
121
|
const action = url.pathname.match(/^\/api\/v1\/jobs\/(job_[a-f0-9]+)\/(approve|cancel|retry|request_changes)$/);
|
|
118
122
|
if (action) return send(200, await queue.action(action[1], action[2], input));
|