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.
@@ -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 (run('git',['-C',repo,'rev-parse','--show-toplevel']) !== repo) throw new Error('--repo must be the Git root');
40
- run('git',['-C',repo,'rev-parse','HEAD']);
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
- mkdirSync(repo,{recursive:true,mode:0o700});run('git',['init','-b','main',repo]);
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 New build/check/review after a stopped review
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
@@ -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 | Controlled-checkout workaround | Not implemented | Not implemented | #28, same recorded source in both interfaces |
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
@@ -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
- Verification commands receive `FACTORY_BASE_REVISION`, the resolved 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.
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 creates a new implementation sequence while preserving earlier attempts.
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
- - `retry JOB_ID` reconciles the previous process group and containers. A live or unknown writer blocks retry. For a new build/defence attempt, the prior checkout is retained as previous-checkout-*.
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 from the source repository with the accumulated feedback. It does not reuse earlier approval.
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 configured source with that
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. **Retry** only repeats the stopped phase and is suitable for a repaired
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
+ }
@@ -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 gitEnv = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' };
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
- run('git',['-c','core.hooksPath=/dev/null','-c','core.fsmonitor=false','clone','--no-hardlinks','--',config.repo,workspace], { env: gitEnv });
112
- git('remote','remove','origin');
113
- save(join(folder,'candidate.json'), { base: git('rev-parse','HEAD'), head: git('rev-parse','HEAD'), synthetic: harnessOf(config) === 'mock' });
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
- writeFileSync(join(output,'handoff.md'), `Ready for manual handoff at ${meta.head}.\nNo PR, merge or deployment performed.\nSee docs/quickstart.md for applying the reviewed change.patch to your own branch.\n`);
150
- save(join(folder,'accepted.json'), { head: meta.head, acceptedAt: new Date().toISOString() });
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 = execFileSync('git', ['-c', 'core.fsmonitor=false', '-C', repo, 'config', '--local', '--get', 'remote.origin.url'], { encoding:'utf8', timeout:2000, maxBuffer:4096, stdio:['ignore','pipe','ignore'], env:{...process.env,GIT_CONFIG_NOSYSTEM:'1',GIT_CONFIG_GLOBAL:'/dev/null'} }).trim();
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 { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
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) {
@@ -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 { execFileSync } from 'node:child_process';
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 = execFileSync('git', ['-c', 'core.fsmonitor=false', '-C', repo, 'config', '--local', '--get', 'remote.origin.url'], {
15
- encoding: 'utf8', timeout: 2000, maxBuffer: 4096, stdio: ['ignore', 'pipe', 'ignore'],
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()) if (['running', 'cancelling'].includes(job.state)) {
22
- job.state = 'interrupted';
23
- Object.assign(job.runs.at(-1), { state: 'interrupted', completed_at: now(), error: 'Controller stopped before completion was confirmed.' });
24
- this.save(job);
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
- const job = { id: id('job'), 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,
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); this.schedule(); return { id: job.id };
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('Only a reviewed software task can be revised');
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
- await this.reconcile(jobId, 'build');
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'; this.save(job); this.schedule();
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);
@@ -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 queue = new JobQueue(state, adapter);
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, can_request_changes: queue.canRequestChanges(job), runs: job.runs.map(attempt => attemptPresentation({ ...attempt,
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
- return send(201, queue.submit(input));
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));