test(lifecycle): cover three delivery paths and crash recovery (#1839)
This commit is contained in:
1 parent
81e54bf4d8
commit
51525a31d6
20 files changed
+1031
-4
No files matched your search
@@ -6132,3 +6132,13 @@ Local commit only; no push or live instance/credential-file access.
|
||||
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8, stopped on brief/base conflict | Task 8 requires attach_existing_and_revise_missing_requirement to attach to an authorized existing product, reuse a matching approved snapshot or revise when missing, and deliver without creating another project. At exact base 76f8d365 the shipped hostPreparation in packages/bus/src/request-host.mjs supplies only preparePlans/admitPlan; neither it nor runtime/broker supplies listProjects or prepareExisting. request-lifecycle projects() therefore returns [] for every production-host picker; attachment refuses request-project-refused. The existing-path unit fixtures inject both missing services. Even with those test-only injections, selection.confirmed explicitly leaves existing associations in discovery/request-attachment-unavailable; runBootstrap skips existing bindings, and no shipped transition adopts the existing approved snapshot. Reproduction: /mnt/storage/scratch/tmp/fayxaI/attach-contract.test.mjs starts the real file-backed runtime with shipped role definitions and scopedFixture (only SCOPES imported from world, no permissive AUTHORITY/world broker), bootstraps timer through actual owner selection and product PRD approval, proves its snapshot resolves, opens a second request and resolves existing through authenticated Console verbs. Expected picker includes timer; actual []. Retained log /mnt/storage/scratch/tmp/fayxaI/attach-contract-red.log: 0 pass, 1 assertion failure, 0 skips/cancellations; fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX with pipefail. This is a deterministic missing host capability, not a load failure. Per explicit assignment rule, no implementation chosen or production behavior changed; remaining harness/live-runner work and broad gates not run, no commit because acceptance is not green. Lead resolution needed: authorize Task 8 to implement the missing existing-product host preparation/attachment/snapshot-adoption path, or provide a revised base/brief that supplies it. No roles, ROUTED, security boundary, shipped migration, canonical tree, real credential/config or live host touched.
|
||||
|
||||
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8a, after | Lead ruled the missing existing-project host path an in-scope gap at 76f8d365, superseding the stop without changing its record. Wired the shipped host inventory/preparation to current owner/business mapping, project config, admitted product registry and existing role/tracker/mode authority. Picker rows carry the organization mapping revision; only the server retains the displayed inventory, and the owner pick journals that revision. A host restart before picking requires a fresh read. The exact current mapping, source commit, existing paths, tracker, staff and mode form the confirmation; stale picker, pick or confirmation refuses. Attachment holds the organization lock and processing lease through validation and its atomic bootstrap-kind receipt, creating no resource. A validated active product snapshot with the same exact original intent is a conservative proven fit and is adopted without a new approval; other intent enters PM discovery/revision, and the new attached request cannot borrow the older active snapshot for task creation. This adds bounded request binding under existing authority, no role, action, capability, ROUTED, Console whitelist, transport proof or security boundary change. Existing bootstrap.completed and attach.selected payloads gain optional host-validated attachment fields; historical rows are unchanged, no new event kind/table or migration, schema remains 3s. Retry after temporary records-read refusal now resumes instead of getting stuck in an invalid transition. README documents freshness and the conservative fit rule. Red logs: 8a-red.log (6/6 fail), 8a-red-scope-http.log (borrowed snapshot accepted; HTTP fixture initially missed log callback), corrected 8a-red-http.log (revision absent), 8a-red-retry.log (working-file dirt was not a valid committed-read refusal), corrected 8a-red-retry-real.log (repair could not resume). Original retained diagnostic went red-to-green; focused compatibility 105/105, final new tests 8/8. Final required gates, zero failures/cancellations/skips: bus 1580/1580; harness npm test 234/234; business npm test 307/307; seat npm test 239/239; whole webui node --test tests/ with concurrency 4 and MOSAIC_REQUIRE_EDITOR_BUILD=1 563/563. All commands had fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX and pipefail; logs /mnt/storage/scratch/tmp/UMYC56/8a-final-{bus,harness,business,seat,webui}.log, 8a-green-final.log, 8a-static.log. Earlier green runs retained separately. Changed-module syntax, relative imports, documentation/reference review and git diff --check pass. Root dependency link and real webui directory of per-entry links only, no install. Pending before/stop SESSIONS and BUILD-LOG lines included as instructed. Local Task 8a commit only; Task 8b and lead-only live window remain.
|
||||
|
||||
2026-11-04 | Codex implementation sub-agent | #797 / Gitea #1839 lifecycle Task 8b, before/after | Before: Task 8a local 81e54bf4d82f670c5c2aba947e650bc5d74a90f2 supplies the missing host path, but there was no three-path process acceptance fixture, standalone offline timer acceptance or guarded live runner. After: added the original owner-supplied quotation with its unchanged source IDs/SHA/provenance and an explicit distinction from newly generated runtime input/message IDs; real file-backed startBroker in a killable process; persistent parent FakeVikunja HTTP service with scoped fake credentials and valid isolated organization; shipped role definitions, real chief launch authority/limits, resolver/bundles/session manifests, independently spawned typed-tool callers, real record/PRD/task validation, Git socket/bundles, separate coder/reviewer workspaces and run identities, independent committed Node timer test execution, and signed-in fake-OP Console HTTP/browser actions. Scripted model responses supply agent-proposed 25/5 durations, start/pause/reset and a gesture-enabled cue only through the approved product PRD. The coder writes actual self-contained HTML, with request/snapshot provenance metadata; the reviewer fetches that exact commit, runs its test and writes the verification record before its verdict. Real close/deliver and chief → assistant → user messages complete the reviewed local delivery; no merge, remote publish or deployment is implied. Autonomous original-wording, guided question/reject/revise/exact approval, existing matching snapshot and missing-requirement revision, cancellation and mode change during approval all pass. Eight hard process crashes (selection resolution, mapping, intent/spec commit, PRD resolution, tracker POST, working push, review verdict, export before notification) restart over the same on-disk Store/repositories and surviving fake service; tests require unchanged historical rows, identical known IDs/commits and one product/task/artifact. A hidden remote POST outcome stays uncertain without a replacement POST or delivery claim and continues once proof is readable. Chromium controls time and verifies work/break transitions, pause/resume/reset, cue after a user gesture and no external requests while offline. Fixture success is integration/recovery evidence, not live model quality or deployment readiness.
|
||||
|
||||
Task 8b defects reproduced before fixes: owner answer/attach/cancel controls on assistant-origin requests tried to thread a human→PM message into the assistant session, correctly refused by SQLite; controls now use the PM inbox unless the origin session is itself PM-owned, leaving the original request/session binding and message graph intact. Cancellation in PRD review previously selected the closed bootstrap decision; it now withdraws only the request's exact current pending PRD decision, with existing owner, PM, project and messaging checks. Targeted production regressions 2/2 red then green (8b-red-controls.log, 8b-green-controls.log). No schema/migration, shipped role JSON, ROUTED, capability, Console whitelist, transport-proof or security-boundary change. Existing bootstrap.completed/attach.selected extension from 8a remains schema 3s. New live-runner preflight tests 2/2 red then green. Repository live runner requires an explicit named isolated development instance, matching marker/config, expected business owner, existing staff/source/tracker/host, owner process proof and explicit run authorization before submission; it never starts hosts, supplies mode/PRD approval, provisions accounts/boards/grants, retries an unknown submission, or defaults to .53/.54/production. It retains request revisions and review/task/download receipts; exact artifact hash/length are checked separately from live browser acceptance.
|
||||
|
||||
Task 8b verification corrections: early harness runs exposed fixture cleanup ordering, unchanged record writes, globally reused create ops, missing persisted launch proof fields, a reused commit with nothing to push, a cross-request timestamp assertion and a missing empty owner-audit file. Those were corrected with the same strict production APIs; logs 8b-autonomous-*.log, 8b-integration-{1,2,4}.log and 8b-paths-recovery-3.log remain. The first autonomous prototype run was interrupted after a cleanup hang; it is recorded as a cancellation, not a passing run. Final required two-file acceptance run 16/16, zero skips/cancellations (8b-integration-final.log). Full CLI first run: 1192 tests, 1139 pass, 52 fail, 1 skip (8b-cli.log). Every failing file reproduced alone: lifecycle, proxy-launch-order, session-clear, thread-launch and thread-revoke (isolated-*.test.mjs.log). Their trusted-host fixtures lacked the request-host operations already used by the landed launcher. A shared test-only forwarding table now calls the actual runtime methods, with no unconditional allow/ready result; all 60 affected tests pass (8b-launcher-fixture-green.log, 8b-thread-fixture-green.log). The ordinary CLI suite intentionally skips R7; the final full npm run enabled R7 under an isolated fake HOME and its existing group/port/socket cleanup wrapper, adapted outside the repo only to run the complete suite. No federation/proof policy or production script changed. Final CLI 1192/1192, zero skips, with R7 acceptance and clean child/listener cleanup (8b-cli-final.log).
|
||||
|
||||
Task 8b final whole-package gates, all zero failures/cancellations/skips: bus 1582; CLI 1192; tasks 132; project 50; harness npm test 234; business npm test 307; seat npm test 239; whole WebUI node --test tests/ with concurrency 4 and MOSAIC_REQUIRE_EDITOR_BUILD=1 564. Total 4300. Every test/gate invocation used fresh mktemp TMPDIR under /mnt/storage/scratch/tmp/XXXXXX, pipefail and retained logs; no install. Root node_modules remains a link; WebUI node_modules remains a real directory of per-entry links. Gate matrix /mnt/storage/scratch/tmp/tupyBK/8b-gate-results.json; 16 case-labeled immutable evidence sets indexed by /mnt/storage/scratch/tmp/tupyBK/8b-acceptance-evidence-index.json, including artifact bytes, original quote, live fixture IDs, histories, manifests, coder/reviewer test logs and crash-lock proof. Syntax, import/Markdown references, whitespace and byte preservation of shipped authority/migrations passed (8b-static-final.log). Self-review performed; no sub-agent/delegate was started. Queue coordination remains with the lead after the linked-worktree refusal. Local second commit only; no push or canonical working-tree/live-instance/real-credential action.
|
||||
|
||||
Lead-only live window checks, not executed here: (1) explicitly authorize and select a named isolated development instance on .53 with its own data root/marker, installed source/board, scoped credentials, real PM/CTO/chief/coder/reviewer/assistant routes and signed-in owner Console; retain preflight and refuse any missing resource rather than provision it. (2) Run packages/cli/live/request-lifecycle.mjs with explicit --instance, --config, --business, --owner, --request-file and --evidence plus --authorize-isolated-run, from a genuine owner shell so the existing process proof applies; use --submit with an existing owned --thread, or observe the owner's existing --request. The owner selects/confirms the exact displayed autonomous plan once in Console. No user relay, manual task creation or worker shell command is part of the lifecycle. (3) Retain original request/message bytes, exact plan confirmation, approved snapshot/decision, tracker create/assignment, working commit, independent reviewer fetch/test/record/verdict, matching closure, delivery and chief/assistant notification; check identical project/snapshot/task/commit/review/verdict IDs across the chain. (4) Download through the owner-only Console URL, rerun observation with --request and --artifact, and require byte length/SHA to equal the reviewed delivery. Open that downloaded HTML with network disabled and verify the approved durations, start, pause/resume/reset, both transitions and the phase-end cue after Start, with no external requests/assets. Inspect actual independent model/session/test receipts before declaring live acceptance. (5) If live crash checks are authorized, kill only the owned isolated broker at the eight named checkpoints; preserve DB/WAL/SHM and repositories. Follow the existing operator rule: establish the exact writer PID is dead, retain its lock identity, and explicitly remove only the stale lock/socket before supported restart. There is no production automatic stale-lock reclaim and this task does not add one. Require unchanged historical rows, identical known IDs/commits and no duplicate project/task/artifact; an unprovable remote outcome must remain a visible wait. Full invocation and qualifications are in packages/cli/live/README.md. Stage 4 live acceptance remains with the lead.
|
||||
@@ -2025,3 +2025,5 @@ are never rewritten or removed; corrections are new entries.
|
||||
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8 repository acceptance investigation | Stopped under explicit brief-conflict rule: real isolated broker with an approved existing timer snapshot returns an empty owner picker because shipped listProjects/prepareExisting are absent; existing binding also remains request-attachment-unavailable. Diagnostic 0/1, zero skips, retained /mnt/storage/scratch/tmp/fayxaI/attach-contract-red.log. No implementation, broad gates, commit, push or live acceptance; append-only before/stop records retained. Linked-worktree queue refusal left with lead.
|
||||
|
||||
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8a host attachment | Lead authorized filling the missing host gap. Real picker/plan with server mapping revision, locked existing attachment, same-intent snapshot reuse and discovery fallback; stale changes and old-snapshot borrowing refused. Final bus 1580, harness 234, business 307, seat 239, webui 563, all zero skips; retained /mnt/storage/scratch/tmp/UMYC56/. No migration, role/security expansion, install, canonical write, push or live acceptance; local first commit, 8b next.
|
||||
|
||||
2026-11-04 | Codex implementation sub-agent, t3/request-lifecycle-task-8-76f8d365 | #797 / Gitea #1839 Task 8b repository acceptance | Three paths, eight separate-process crashes, tracker uncertainty, real typed tracker/Git/review/delivery, offline Chromium and guarded live runner; assistant-origin owner controls and exact PRD cancellation fixed red-to-green. Five older launcher fixture protocols forwarded to real request-host methods. Final gates 4300 tests across bus/CLI/tasks/project/harness/business/seat/WebUI, zero failures/skips/cancellations; full CLI includes opt-in R7 in isolated fake HOME. Earlier CLI 52 failures/1 skip and isolated reproductions retained. Logs/evidence index /mnt/storage/scratch/tmp/tupyBK/. No migration, role/ROUTED/security expansion, install, canonical edit, live acceptance or push. Second local commit; lead-only live checks in BUILD-LOG and packages/cli/live/README.md.
|
||||
@@ -922,7 +922,10 @@ export class Broker {
|
||||
this.#store.run(
|
||||
'INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
|
||||
mid, this.#now(), s.business, 'human', 'human-console', state.pm, 'INFO', text);
|
||||
const cancelled = this.#route(s.business, mid, state.pm, state.session);
|
||||
// Intake can originate in the assistant's session. Its owner control goes to
|
||||
// the PM's ordinary inbox; only a PM-owned session can contain that message.
|
||||
const pmSession = state.session && this.#store.get('SELECT instance FROM sessions WHERE id=?', state.session)?.instance === state.pm ? state.session : null;
|
||||
const cancelled = this.#route(s.business, mid, state.pm, pmSession);
|
||||
if (cancelled) fail(cancelled);
|
||||
return this.#event(s, 'human.input', { ...body, via: 'console', subject: s.console, message: mid }, mid);
|
||||
}
|
||||
@@ -937,7 +940,9 @@ export class Broker {
|
||||
#withdrawIntake(s, state, decision) {
|
||||
this.#intakeEdge(s, state);
|
||||
const d = this.#decision(s, decision);
|
||||
if (d.action !== 'project.bootstrap' || d.route_to !== 'human' || d.raised_by_role !== state.pm) fail('request-selection-refused');
|
||||
const selection = d.action === 'project.bootstrap' && d.route_to === 'human' && state.selection?.decision === d.id;
|
||||
const prd = d.action === 'prd.approve' && state.phase === 'prd-review' && state.wait?.subject === d.id && d.project === state.project;
|
||||
if ((!selection && !prd) || d.raised_by_role !== state.pm) fail('request-selection-refused');
|
||||
if (this.#closed(d.id)) fail('decision-closed');
|
||||
const seq = Number(this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
d.id, this.#now(), 'withdrawn', s.human, null, null, 'console').lastInsertRowid);
|
||||
|
||||
@@ -427,7 +427,7 @@ export function createRequestLifecycle({ journal, services }) {
|
||||
if (replay(state, args.op, proposal_digest)) return state;
|
||||
if (state.revision !== args.revision) refuse('request-revision-conflict');
|
||||
ended(state);
|
||||
const decision = state.wait?.kind === 'decision' ? state.selection?.decision ?? refuse('invalid-request-event') : null;
|
||||
const decision = state.wait?.kind === 'decision' ? state.wait.subject : null;
|
||||
return journal.transaction(tx => {
|
||||
if (decision !== null) need('withdrawDecision')(identity, structuredClone(state), decision);
|
||||
need('recordCancel')(identity, structuredClone(state), { op: args.op, decision });
|
||||
|
||||
@@ -204,7 +204,7 @@ function reduce(state, e) {
|
||||
exact(b, ['decision'], ['op','proposal_digest'], code);
|
||||
if (('op' in b) !== ('proposal_digest' in b) || ('op' in b && (!id(b.op) || !hash(b.proposal_digest)))) refuse(code);
|
||||
if (s.phase === 'delivered') refuse(code);
|
||||
if (s.wait?.kind === 'decision' ? s.selection?.decision !== b.decision || s.wait.subject !== b.decision : b.decision !== null) refuse(code);
|
||||
if (s.wait?.kind === 'decision' ? s.wait.subject !== b.decision : b.decision !== null) refuse(code);
|
||||
s.phase = 'cancelled'; s.wait = null; s.selection = null;
|
||||
} else refuse(code);
|
||||
return s;
|
||||
|
||||
@@ -5,6 +5,7 @@ import { readFileSync, writeFileSync, readdirSync, renameSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { scopedFixture } from '../../tasks/tests/scoped-fixture.mjs';
|
||||
import { git } from '../../project/tests/prd-fixture.mjs';
|
||||
import { requestFixture } from './request-fixture.mjs';
|
||||
|
||||
async function picker(t, text = 'Build a product.') {
|
||||
const f = await scopedFixture(t), product = await f.product('timer');
|
||||
@@ -123,3 +124,20 @@ test('an attachment read refusal stays visible and resumes after the existing re
|
||||
assert.equal(resumed.phase, 'planning');
|
||||
assert.equal(resumed.snapshot, f.product.snapshot.id);
|
||||
});
|
||||
|
||||
test('owner answers on an assistant-origin request do not put a PM message in the assistant session', async t => {
|
||||
const f = await requestFixture(t, 'guided', null, true);
|
||||
let s = await f.call('pm', 'request.get', { request: f.request });
|
||||
s = await f.call('pm', 'request.question', { request: f.request, revision: s.revision, op: 'thread-question', text: 'Offline?' });
|
||||
const answered = await f.call('console', 'request.answer.console', { request: f.request, revision: s.revision, question: s.question.id, op: 'thread-answer', text: 'Yes.' });
|
||||
assert.ok(answered.question.answer);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM message_threads mt JOIN messages m ON m.id=mt.message WHERE mt.session=? AND m.to_role='pm' AND m.from_role='human'", s.session).n, 0);
|
||||
});
|
||||
|
||||
test('cancel during guided PRD approval withdraws its exact pending decision', async t => {
|
||||
const f = await requestFixture(t, 'guided', null, true), submitted = await f.submit();
|
||||
const s = await f.call('pm', 'request.get', { request: f.request });
|
||||
const cancelled = await f.call('console', 'request.cancel.console', { request: f.request, revision: s.revision, op: 'cancel-prd' });
|
||||
assert.equal(cancelled.phase, 'cancelled');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND op='withdrawn'", submitted.decision.id).n, 1);
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
# Request lifecycle acceptance
|
||||
|
||||
`request-lifecycle.mjs` is an opt-in submission/observation runner for a later
|
||||
lead-authorized isolated instance. It uses the existing owner process proof and
|
||||
the running production host. It does not start a host, launch a scripted role,
|
||||
select resources or a mode, approve a PRD, mint credentials or provision services.
|
||||
|
||||
Prepare a named development instance with its matching `instance.json`, an
|
||||
existing factory source, installed tracker board and scoped credentials, and
|
||||
real PM, CTO, chief, coder, reviewer and assistant routes. Start that instance
|
||||
through the supported entry points in the authorized window. The owner supplies
|
||||
an existing assistant thread. The runner requires an explicit instance, config,
|
||||
business, expected owner, request quotation and evidence output directory.
|
||||
`--owner` checks the configured identity; it does not authenticate anybody.
|
||||
|
||||
Example with deliberate placeholders (replace every uppercase value):
|
||||
|
||||
```sh
|
||||
node packages/cli/live/request-lifecycle.mjs \
|
||||
--instance pomo797 --config /ABS/CONFIG.json --business BUSINESS --owner OWNER \
|
||||
--thread THREAD_UUID --submit --authorize-isolated-run \
|
||||
--request-file /ABS/pomodoro-request.json --evidence /ABS/EVIDENCE
|
||||
```
|
||||
|
||||
The owner selects and confirms the exact displayed plan in the signed-in Console.
|
||||
Autonomous PRD approval remains with the distinct technical arbiter. Guided mode
|
||||
requires the owner's answers and exact-version approval. No owner relays messages,
|
||||
creates a task manually, or runs worker shell commands. The quotation in
|
||||
`../tests/fixtures/pomodoro-request.json` preserves the owner's original wording
|
||||
and provenance; it is not a raw runtime event export. The new runtime event and
|
||||
message IDs are retained separately.
|
||||
|
||||
The runner retains each observed request revision and final task/review trails.
|
||||
On complete reviewed delivery it reports the owner-only Console download URL.
|
||||
Download there, then rerun observation with `--request EVENT_UUID` and
|
||||
`--artifact /ABS/DOWNLOADED.html` in place of `--submit --thread THREAD_UUID`.
|
||||
The runner checks the exact reviewed artifact length and SHA-256 and keeps a copy.
|
||||
Unknown outcomes and timeouts retain evidence and fail; they never trigger a
|
||||
replacement submission or approval. Output files are created exclusively.
|
||||
|
||||
For the lead's live acceptance, retain the original input/message, owner plan
|
||||
confirmation, active approved snapshot, tracker create/assignment, code commit,
|
||||
independent reviewer fetch/test record and verdict, closure, delivery receipt,
|
||||
chief/assistant notification and downloaded bytes. Check that their project,
|
||||
snapshot, task, commit, review and verdict IDs form the same chain. In an offline
|
||||
browser, check the approved proposal's work/break durations, start, pause/resume,
|
||||
reset, phase transition and cue after a user gesture, and no network requests or
|
||||
external assets. The defaults are agent proposals approved in the product PRD.
|
||||
Inspect real independent role/test receipts before declaring live acceptance.
|
||||
|
||||
The repository fixture runs a separate runtime process over the same persistent
|
||||
fake tracker and real Store/repositories after each crash. Its scripted models
|
||||
use shipped role authority, launch admission/limits, session manifests, real typed
|
||||
tool subprocesses and Git bundles; coder and reviewer run the committed Node
|
||||
acceptance test independently. It exercises the signed-in HTTP listener and
|
||||
Chromium. It does not demonstrate live model quality or deployment readiness.
|
||||
|
||||
Crash restart follows the existing [operator recovery rule](../../bus/README.md):
|
||||
there is no automatic stale writer-lock reclaim. The fixture kills and reaps only
|
||||
its own process group, verifies that exact PID is dead, records the stale lock's
|
||||
identity and removes only its lock/socket before restart. The database, WAL and
|
||||
SHM are preserved. A live operator must establish the same facts and preserve
|
||||
evidence before recovery; this harness does not change that production rule.
|
||||
@@ -0,0 +1,111 @@
|
||||
// Opt-in live acceptance observer/submission. Never starts a host, selects a mode,
|
||||
// approves a PRD, creates a service resource, or substitutes a scripted role.
|
||||
import { readFileSync, writeFileSync, mkdirSync, lstatSync } from 'node:fs';
|
||||
import { dirname, isAbsolute, join, resolve, relative } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { loadSystem, bootConfig, rolesDir } from '../src/config.mjs';
|
||||
import { loadBusiness } from '../../business/src/index.mjs';
|
||||
import { readHostState } from '../src/host.mjs';
|
||||
import { humanTransport, refuseInsideAgent } from '../src/transport.mjs';
|
||||
import { staffFor } from '../../bus/src/request-host.mjs';
|
||||
|
||||
const fail = message => { throw new Error(message); };
|
||||
export function parseOptions(argv) {
|
||||
const result = {};
|
||||
const flags = { '--authorize-isolated-run': 'authorized', '--submit': 'submit' };
|
||||
const values = { '--instance':'instance', '--evidence':'evidence', '--config':'config', '--business':'business', '--owner':'owner',
|
||||
'--request-file':'requestFile', '--request':'request', '--thread':'thread', '--artifact':'artifact', '--timeout-ms':'timeout' };
|
||||
for (let n = 0; n < argv.length; n++) {
|
||||
const key = flags[argv[n]] ?? values[argv[n]];
|
||||
if (!key || key in result) fail('unknown or duplicate option');
|
||||
result[key] = argv[n] in flags ? true : argv[++n];
|
||||
if (result[key] === undefined) fail('missing option value');
|
||||
}
|
||||
if (!/^[a-z][a-z0-9]{1,15}$/.test(result.instance ?? '') || ['dev','prod','production'].includes(result.instance)) fail('explicit named isolated --instance required');
|
||||
if (!result.authorized) fail('explicit owner authorization --authorize-isolated-run required');
|
||||
for (const key of ['config','evidence','requestFile']) if (!isAbsolute(result[key] ?? '')) fail('explicit absolute --' + key + ' required');
|
||||
if (!result.business || !result.owner) fail('explicit business and expected owner required');
|
||||
if (result.submit && (!result.thread || result.request)) fail('--submit requires an existing owner --thread and no --request');
|
||||
result.timeout = result.timeout === undefined ? 300000 : Number(result.timeout);
|
||||
if (!Number.isSafeInteger(result.timeout) || result.timeout < 1000 || result.timeout > 3600000) fail('timeout must be 1000..3600000 ms');
|
||||
return result;
|
||||
}
|
||||
export function checkResources({ options, system, business, marker, host, tracker }) {
|
||||
if (system.environment !== 'development') fail('isolated development instance required');
|
||||
if (marker?.instanceVersion !== 1 || marker.name !== options.instance) fail('instance marker mismatch');
|
||||
if (business.id !== options.business || business.human !== options.owner) fail('owner/business mismatch');
|
||||
if (!host?.live || (host.business && host.business !== business.id)) fail('configured live host required; runner does not start one');
|
||||
if (!Number.isSafeInteger(tracker?.project) || tracker.project < 1) fail('tracker-install: declared installed tracker root required');
|
||||
const pms = Object.keys(business.roles).filter(r => business.roles[r].definition === 'pm');
|
||||
if (pms.length !== 1 || !staffFor(business, pms[0])) fail('configured PM, independent technical arbiter, chief, coder and reviewer staff required');
|
||||
if (!Object.values(business.projects).some(p => p.kind !== 'library')) fail('declared factory source project required');
|
||||
}
|
||||
function json(file) {
|
||||
const stat = lstatSync(file);
|
||||
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) fail('bounded regular input required');
|
||||
return JSON.parse(readFileSync(file, 'utf8'));
|
||||
}
|
||||
export async function main(argv, env = process.env) {
|
||||
const options = parseOptions(argv);
|
||||
refuseInsideAgent(env); // --owner is an expectation, never authentication or a capability.
|
||||
const selected = { ...env, MOSAIC_CONFIG: options.config };
|
||||
const system = loadSystem({ env: selected }), config = bootConfig({ system, businessId: options.business, env: selected });
|
||||
const business = loadBusiness(options.business, { dir: dirname(options.config), rolesDir: rolesDir(selected) });
|
||||
const host = readHostState(system.dataRoot);
|
||||
checkResources({ options, system, business, marker: json(join(system.dataRoot, 'instance.json')), host, tracker: config.trackers?.[options.business] });
|
||||
for (const [name, role] of Object.entries(business.roles)) {
|
||||
if (!role.vars?.harness || !role.vars?.model || !role.vars?.thinking) fail('model route missing for ' + name);
|
||||
}
|
||||
const source = json(options.requestFile);
|
||||
if (typeof source.text !== 'string' || !source.text || source.provenance?.raw_runtime_event_exported !== false ||
|
||||
createHash('sha256').update(source.text).digest('hex') !== source.source_sha256) fail('verified owner-supplied original request quotation required');
|
||||
const call = humanTransport({ socket: host.socket ?? join(system.dataRoot, 'bus/broker.sock'), business: business.id, env: selected });
|
||||
await call('instances'); // Existing owner process proof succeeds before any submission.
|
||||
const credentials = await call('credentials');
|
||||
if (Array.isArray(credentials) && credentials.some(c => c.service === 'vikunja' && !['valid','ready'].includes(c.state))) fail('tracker credential setup wait; inspect Console credentials');
|
||||
const rel = relative(system.dataRoot, resolve(options.evidence));
|
||||
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) fail('evidence output must be outside the instance data root');
|
||||
mkdirSync(options.evidence, { recursive: true, mode: 0o700 });
|
||||
if (!lstatSync(options.evidence).isDirectory() || lstatSync(options.evidence).isSymbolicLink()) fail('plain evidence output directory required');
|
||||
const record = (kind, value) => {
|
||||
const file = join(options.evidence, `${kind}-${randomUUID()}.json`);
|
||||
writeFileSync(file, JSON.stringify(value, null, 2) + '\n', { flag: 'wx', mode: 0o600 }); return file;
|
||||
};
|
||||
record('preflight', { instance: options.instance, business: business.id, owner: business.human, source,
|
||||
qualification: 'Owner-supplied incident quote; new runtime submission IDs are recorded separately. Fixture success is not live model acceptance.' });
|
||||
let request = options.request;
|
||||
if (options.submit) {
|
||||
const threads = await call('thread.list');
|
||||
if (!threads.some(t => t.id === options.thread)) fail('the explicitly selected thread is not owned by this user');
|
||||
const sent = await call('thread.send', { thread: options.thread, body: source.text });
|
||||
request = sent.request;
|
||||
record('submission', { request, message: sent.id, original: { source_event: source.source_event, source_message: source.source_message, source_sha256: source.source_sha256 } });
|
||||
process.stdout.write(`Request ${request}. Select and confirm the exact plan in the signed-in Console.\n`);
|
||||
}
|
||||
if (!request) fail('--submit or an existing --request is required');
|
||||
const end = Date.now() + options.timeout;
|
||||
let state, revision = null;
|
||||
while (Date.now() < end) {
|
||||
try { state = await call('request.get', { request }); }
|
||||
catch (e) { if (e.code !== 'request-not-found') throw e; }
|
||||
if (state && state.revision !== revision) { record('request', state); revision = state.revision; }
|
||||
if (state?.phase === 'cancelled') fail('request cancelled; receipts retained');
|
||||
if (state?.phase === 'complete' && state.delivery_evidence) break;
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
}
|
||||
if (!state?.delivery_evidence || state.phase !== 'complete') fail('live acceptance incomplete: exact current wait is retained in request evidence');
|
||||
const delivery = state.delivery_evidence;
|
||||
const trails = [];
|
||||
for (const subject of [request, state.source_message, ...state.tasks, delivery.review_request, delivery.verdict]) trails.push({ subject, rows: await call('trail', { subject }) });
|
||||
record('delivery', { state, trails, verified_local_delivery_only: true });
|
||||
if (!options.artifact) fail(`download required from signed-in Console /api/requests/${request}/artifact/${delivery.id}; rerun observation with --artifact PATH`);
|
||||
const bytes = readFileSync(options.artifact);
|
||||
if (bytes.length !== delivery.artifact.bytes || createHash('sha256').update(bytes).digest('hex') !== delivery.artifact.sha256) fail('download differs from reviewed artifact');
|
||||
writeFileSync(join(options.evidence, 'reviewed-' + delivery.id + '.html'), bytes, { flag: 'wx', mode: 0o600 });
|
||||
record('download', { request, delivery: delivery.id, artifact: delivery.artifact, qualification: 'Hash-verified live delivery; lead must run offline behavior checks and inspect real independent role/test receipts before acceptance.' });
|
||||
process.stdout.write('Live delivery and downloaded bytes retained. Offline browser verification and lead acceptance remain explicit.\n');
|
||||
}
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
||||
main(process.argv.slice(2)).catch(e => { process.stderr.write(e.message + '\n'); process.exitCode = 2; });
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"source_event": "4dfade7e-ded6-4997-8cf6-8283f2c6260a",
|
||||
"source_message": "d70096aa-5c50-4674-bbc8-5aed0b189810",
|
||||
"text": "Let's try the pomodoro timer app in a self-contained html file. Have an agent handle that for me.",
|
||||
"source_sha256": "f677c04b87b0226203e068bca29b9117122c4e747dc55b5654fc3f04a917eff7",
|
||||
"provenance": {
|
||||
"kind": "owner-supplied-incident-quote",
|
||||
"thread": "<t3-thread://v2/774bdf7b-6bed-43c4-91d4-3457ae11201a/c5c1e8ba-e5a9-4cb9-bc11-f58358d5d48b>",
|
||||
"source_item": "turn-item:message:3f584acb-a6df-4ea6-b0bc-37c47116fd5b",
|
||||
"chief_relay": "c76bafb0-9a5f-4631-b1e9-15824d2a5bce",
|
||||
"raw_runtime_event_exported": false,
|
||||
"note": "Exact request wording and original event/message identifiers were quoted by the owner. This export preserves that quote; it is not a raw runtime database export or a live acceptance receipt."
|
||||
}
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
// Scripted model responses only. Production tools, authority and evidence writers do the work.
|
||||
import { readFileSync, writeFileSync, mkdirSync, existsSync, appendFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { startBroker } from '../../../bus/src/runtime.mjs';
|
||||
import { createRequestStore } from '../../../bus/src/request-store.mjs';
|
||||
import { bootConfig, loadSystem } from '../../src/config.mjs';
|
||||
import { loadBusiness, resolveInstance, systemVars } from '../../../business/src/index.mjs';
|
||||
import { limitHit } from '../../../business/src/launch.mjs';
|
||||
import { tasksAdapter } from '../../../tasks/src/adapter.mjs';
|
||||
import { createGitSocket } from '../../src/git-socket.mjs';
|
||||
import { pushWorking, fetchReview } from '../../../harness/src/git-tools.mjs';
|
||||
import { callTool } from '../../../harness/src/tools.mjs';
|
||||
import { toolServer } from '../../../harness/src/runner.mjs';
|
||||
import { buildBundle } from '../../../harness/src/bundle.mjs';
|
||||
import { HARNESS } from '../../../harness/tests/helpers.mjs';
|
||||
import { ensureCheckouts } from '../../../seat/src/checkout.mjs';
|
||||
import { newRun, Registry, readSessions, writeSessionFile, workspaceDir } from '../../../seat/src/session.mjs';
|
||||
import { startTimeOf } from '../../src/host.mjs';
|
||||
import { git } from '../../../project/tests/prd-fixture.mjs';
|
||||
|
||||
export const REQUIREMENT = 'REQ-POMO-1';
|
||||
export const PROPOSAL = 'Agent proposal: 25-minute work, 5-minute break, start/pause/reset, a phase-end visual and audible cue after a user gesture. One offline HTML file, no network or external assets.';
|
||||
export const HTML = `<!doctype html>
|
||||
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||
<title>Pomodoro</title><style>body{font:20px system-ui;text-align:center;background:#182523;color:#f5edce;margin:10vh auto}output{display:block;font-size:5rem;font-variant-numeric:tabular-nums}button{font:inherit;margin:.4em;padding:.6em 1em}#cue{min-height:2em}</style>
|
||||
<h1>Pomodoro</h1><h2 id="phase">Work</h2><output id="clock" aria-live="off">25:00</output>
|
||||
<button id="start">Start</button><button id="pause">Pause</button><button id="reset">Reset</button><p id="cue" role="status"></p>
|
||||
<script>
|
||||
(() => {
|
||||
const clock=document.querySelector('#clock'), phase=document.querySelector('#phase'), cue=document.querySelector('#cue');
|
||||
let work=true, left=25*60*1000, running=false, last=0, audio=null;
|
||||
const render=()=>{const s=Math.ceil(left/1000);clock.textContent=String(Math.floor(s/60)).padStart(2,'0')+':'+String(s%60).padStart(2,'0');phase.textContent=work?'Work':'Break';};
|
||||
const signal=()=>{cue.textContent=work?'Break finished. Time to work.':'Work finished. Take a break.';if(audio){const o=audio.createOscillator(),g=audio.createGain();o.connect(g);g.connect(audio.destination);g.gain.value=.12;o.frequency.value=660;o.start();o.stop(audio.currentTime+.2);}};
|
||||
const tick=()=>{if(!running)return;const now=Date.now();left-=Math.max(0,now-last);last=now;while(left<=0){work=!work;left+=(work?25:5)*60*1000;signal();}render();};
|
||||
document.querySelector('#start').addEventListener('click',()=>{if(!audio){const A=window.AudioContext||window.webkitAudioContext;if(A)audio=new A();}audio?.resume();if(!running){last=Date.now();running=true;}});
|
||||
document.querySelector('#pause').addEventListener('click',()=>{tick();running=false;});
|
||||
document.querySelector('#reset').addEventListener('click',()=>{running=false;work=true;left=25*60*1000;cue.textContent='';render();});
|
||||
setInterval(tick,100);render();
|
||||
})();
|
||||
</script></html>`;
|
||||
|
||||
// Executed independently by coder and reviewer against the actual HTML bytes.
|
||||
export function verifyHTML(bytes) {
|
||||
if (!bytes.includes('25:00') || /https?:\/\/|<script[^>]+src=|<link[^>]+href=/i.test(bytes)) throw new Error('offline acceptance failed');
|
||||
return 'Passed offline single-file checks, 25/5 proposal, start/pause/reset and phase-end cue; browser acceptance is recorded separately by the integration gate.';
|
||||
}
|
||||
|
||||
const TIMER_TEST = `import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
test('approved offline timer starts, pauses, resumes, changes phase and resets',()=>{
|
||||
const bytes=readFileSync(new URL('./pomodoro.html',import.meta.url),'utf8');
|
||||
assert.doesNotMatch(bytes,/https?:\\/\\//);
|
||||
const elements=Object.fromEntries(['clock','phase','cue','start','pause','reset'].map(id=>[id,{textContent:'',addEventListener(_,fn){this.click=fn;}}]));
|
||||
let now=0,tick;const context={document:{querySelector:s=>elements[s.slice(1)],addEventListener(){}},window:{},Date:{now:()=>now},setInterval:fn=>{tick=fn;}};
|
||||
vm.runInNewContext(bytes.match(/<script>([\\s\\S]*?)<\\/script>/)[1],context);
|
||||
assert.equal(elements.clock.textContent,'25:00');elements.start.click();now=1000;tick();assert.equal(elements.clock.textContent,'24:59');
|
||||
elements.pause.click();now=20000;tick();assert.equal(elements.clock.textContent,'24:59');elements.start.click();now+=1499000;tick();assert.equal(elements.phase.textContent,'Break');assert.equal(elements.clock.textContent,'05:00');
|
||||
elements.reset.click();assert.equal(elements.phase.textContent,'Work');assert.equal(elements.clock.textContent,'25:00');
|
||||
});`;
|
||||
|
||||
const run = promisify(execFile), SELF = fileURLToPath(import.meta.url);
|
||||
if (process.argv[2] === '--tool-call') {
|
||||
try { process.stdout.write(JSON.stringify(await callTool(process.argv[3], process.argv[4], JSON.parse(process.argv[5]))) + '\n'); }
|
||||
catch (e) { process.stderr.write(e.message + '\n'); process.exitCode = 1; }
|
||||
} else if (process.argv[2] === '--runtime') await runtime();
|
||||
|
||||
async function runtime() {
|
||||
const root = process.env.REQUEST_FIXTURE_ROOT, dataRoot = join(root, 'data'), metaFile = join(root, 'scenario.json');
|
||||
const system = loadSystem(), config = bootConfig({ system, businessId: 'demo', warn: () => {} });
|
||||
let armed = process.env.REQUEST_CRASH_POINT ?? null;
|
||||
const checkpoint = async point => {
|
||||
if (armed !== point) return;
|
||||
process.send({ checkpoint: point });
|
||||
await new Promise(() => {});
|
||||
};
|
||||
const old = readSessions(dataRoot), roles = {}, servers = [], registry = new Registry(dataRoot);
|
||||
let adapter;
|
||||
const factory = tasksAdapter({ trackers: config.trackers, autostart: false, log: () => {} });
|
||||
const bus = await startBroker({ ...config, requestServices: { barrier: checkpoint },
|
||||
tasks: async args => (adapter = await factory(args)) });
|
||||
await adapter.start('demo');
|
||||
if (adapter.status()[0].state !== 'ready') throw new Error('fixture tracker not ready');
|
||||
for (const s of old) bus.recoverLaunch(s.launch_record);
|
||||
const db = new DatabaseSync(join(dataRoot, 'bus/bus.sqlite'), { readOnly: true });
|
||||
const store = { get: (sql, ...args) => db.prepare(sql).get(...args), all: (sql, ...args) => db.prepare(sql).all(...args) };
|
||||
const journal = createRequestStore(store);
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (role, verb, args = {}) => bus.broker.request(role === 'human' ? human : roles[role].cap, { verb, args });
|
||||
const metadata = () => existsSync(metaFile) ? JSON.parse(readFileSync(metaFile)) : {};
|
||||
const save = obj => writeFileSync(metaFile, JSON.stringify({ ...metadata(), ...obj }) + '\n');
|
||||
const state = () => journal.readRequest('demo', metadata().request);
|
||||
const rows = (kind, task) => store.all('SELECT * FROM events WHERE business=? AND kind=? ORDER BY seq', 'demo', kind)
|
||||
.map(e => ({ ...e, body: JSON.parse(e.body) })).filter(e => !task || e.subject === task);
|
||||
let socket;
|
||||
const refreshGit = async () => {
|
||||
await socket?.close();
|
||||
const b = loadBusiness('demo', { dir: dirname(process.env.MOSAIC_CONFIG), rolesDir: process.env.MOSAIC_ROLES_DIR });
|
||||
socket = createGitSocket({ host: { op: async r => {
|
||||
if (r.op === 'identity') return bus.identity(r.cap);
|
||||
if (r.op === 'checkPush') return bus.checkPush(r.cap, r.task_ref, r.project);
|
||||
if (r.op === 'recordPush') return bus.recordPush(r.cap, r.push);
|
||||
if (r.op === 'reviewFetch') return bus.reviewFetch(r.cap, r.args);
|
||||
if (r.op === 'refuse') return bus.refuse(r.cap, r.code, r.subject);
|
||||
throw new Error('unexpected git operation');
|
||||
} }, business: b, dataRoot, env: process.env });
|
||||
await socket.listen();
|
||||
};
|
||||
async function launch(role, thread = null) {
|
||||
if (roles[role]) return roles[role];
|
||||
const b = loadBusiness('demo', { dir: dirname(process.env.MOSAIC_CONFIG), rolesDir: process.env.MOSAIC_ROLES_DIR });
|
||||
const resolved = resolveInstance({ system: systemVars(system), business: b, instance: role });
|
||||
if (!['chief','assistant'].includes(role)) bus.authorizeLaunch(roles.chief.cap, role);
|
||||
if (limitHit(registry.sessions, b.id, b.launch.limits, { provider: 'zai', model: resolved.vars.model })) throw new Error('fixture launch capacity refused');
|
||||
const may = await bus.mayLaunch({ business: 'demo', instance: role, provider: 'zai', model: resolved.vars.model });
|
||||
if (may?.allow !== true) throw new Error('fixture launch limit refused: ' + may?.code);
|
||||
const { run: id, runDir } = newRun(dataRoot, 'demo'), workspace = workspaceDir(dataRoot, 'demo', role);
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
const projects = Object.fromEntries(Object.entries(b.projects).filter(([project, p]) => p.kind !== 'library' && bus.admission({ business: 'demo', project }).ready));
|
||||
const checkouts = await ensureCheckouts({ workspace, projects });
|
||||
const launch_record = { business: 'demo', role, run: id, harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid), ...(thread ? { thread } : {}) };
|
||||
const cap = bus.bindLaunch(launch_record).cap;
|
||||
roles[role] = { cap, run: id, workspace, runDir };
|
||||
call(role, 'role.claim');
|
||||
const bundle = buildBundle({ dir: join(runDir, 'bundle'), resolved, contract: readFileSync(resolved.contract, 'utf8'),
|
||||
arbiter: role === 'cto', tracker: Boolean(resolved.tracker), credentials: [], skillsDir: join(dataRoot, 'skills-enabled'), harness: HARNESS.pi,
|
||||
harnessVersion: '0.85.1', session: { run: id, workspace, checkouts, toolSocket: join(runDir, 'tools.sock'), turnMarker: join(runDir, 'turn.done') } });
|
||||
const client = { call: async (verb, args) => {
|
||||
const result = verb.startsWith('task.') ? await bus.broker.requestTask(cap, { verb, args }, adapter.handle) : await call(role, verb, args);
|
||||
if (verb === 'decision.resolve') await checkpoint('prd-resolution');
|
||||
if (verb === 'review.verdict') await checkpoint('review-verdict');
|
||||
if (verb === 'request.deliver') await checkpoint('export-before-notification');
|
||||
return result;
|
||||
} };
|
||||
const local = {
|
||||
'working.push': async args => { const r = await pushWorking({ path: socket.path, cap, workspace, ...args, tmp: runDir, env: process.env }); await checkpoint('working-push'); return r; },
|
||||
'working.fetch': args => fetchReview({ path: socket.path, cap, workspace, ...args, tmp: runDir, env: process.env }),
|
||||
};
|
||||
const server = await toolServer({ path: join(runDir, 'tools.sock'), tools: bundle.tools, client, local }); servers.push(server);
|
||||
const entry = { business: 'demo', instance: role, run: id, workspace, runDir, pid: process.pid, startTime: startTimeOf(process.pid), harness: 'pi', provider: 'zai', model: resolved.vars.model,
|
||||
manifest: bundle.tools.map(t => t.name), launch_record };
|
||||
writeSessionFile(runDir, entry); registry.add(entry);
|
||||
return roles[role];
|
||||
}
|
||||
async function tool(role, name, args = {}) {
|
||||
const session = await launch(role, role === 'assistant' ? metadata().thread : null);
|
||||
// The scripted model process receives only its own typed socket, never a tracker token or other role capability.
|
||||
const { stdout } = await run(process.execPath, [SELF, '--tool-call', join(session.runDir, 'tools.sock'), name, JSON.stringify(args)],
|
||||
{ env: { PATH: process.env.PATH, TMPDIR: process.env.TMPDIR }, timeout: 120000, maxBuffer: 4 * 1024 * 1024 });
|
||||
appendFileSync(join(root, 'model-calls.jsonl'), JSON.stringify({ role, run: session.run, tool: name, args }) + '\n');
|
||||
return JSON.parse(stdout);
|
||||
}
|
||||
await refreshGit(); await launch('chief'); await launch('pm'); await launch('cto');
|
||||
if (metadata().thread) await launch('assistant', metadata().thread);
|
||||
async function startRequest(text, attach = false) {
|
||||
const project = call('human', 'project.create', { name: 'Intake ' + Date.now() }).id;
|
||||
const thread = call('human', 'thread.create', { project, name: 'Pomodoro' }).id;
|
||||
if (roles.assistant) { registry.remove('demo', roles.assistant.run); bus.endLaunch({ business: 'demo', run: roles.assistant.run, reason: 'exit', exitCode: 0 }); delete roles.assistant; }
|
||||
await launch('assistant', thread);
|
||||
const source = call('human', 'thread.send', { thread, body: text });
|
||||
save({ request: source.request, source: source.id, thread, attach });
|
||||
const relay = await tool('assistant', 'send_message', { to: 'chief', in_reply_to: source.id, body: 'Handle original request ' + source.request });
|
||||
await tool('chief', 'send_message', { to: 'pm', in_reply_to: relay.id, body: 'Open original request ' + source.request });
|
||||
let s = await tool('pm', 'intake_open', { request: source.request });
|
||||
s = await tool('pm', 'intake_propose', { request: s.request, revision: s.revision, op: 'resource-plan', source_project: 'stack', project: attach ? 'unused' : 'timer', name: attach ? 'Unused' : 'timer', tracker_project: 2 });
|
||||
return s;
|
||||
}
|
||||
async function draft(s) {
|
||||
const submitted = store.all("SELECT body FROM request_events WHERE business='demo' AND json_extract(body,'$.candidate.project')=? AND kind='prd.submitted' ORDER BY seq", s.project).map(r => JSON.parse(r.body));
|
||||
const version = `1.${submitted.length}`;
|
||||
const proposal = PROPOSAL + (journal.intent('demo', s.request).text.includes('keyboard shortcut') ? ' Add an R keyboard shortcut for reset.' : '') + ` PRD revision ${version}.`;
|
||||
await tool('pm', 'write_record', { project: s.project, path: `docs/prd-${version}.md`, record: '# Pomodoro ' + version + '\n\n' + proposal + (submitted.length ? '\nRevision: explicitly satisfy the owner answer and rejected draft feedback.\n' : '\n') });
|
||||
const requirement = await tool('pm', 'write_record', { project: s.project, path: `requirements/${REQUIREMENT}.json`, record: { recordVersion: 1, id: REQUIREMENT, title: 'Offline Pomodoro', text: proposal, status: 'draft' } });
|
||||
return tool('pm', 'submit_prd', { request: s.request, revision: s.revision, op: 'prd-' + s.revision, prd_version: version,
|
||||
records_commit: requirement.commit, prd_path: `docs/prd-${version}.md`, requirement_ids: [REQUIREMENT] });
|
||||
}
|
||||
async function drive() {
|
||||
await bus.requestLifecycle.recover();
|
||||
for (let count = 0; count < 24; count++) {
|
||||
let s = state(); if (!s) return null;
|
||||
if (s.phase === 'cancelled') return s;
|
||||
if (s.phase === 'selection') {
|
||||
if (s.wait?.code === 'request-attachment-selected') {
|
||||
const project = s.wait.subject;
|
||||
const entries = await tool('pm', 'intake_projects', { request: s.request });
|
||||
const entry = entries.find(e => e.project === project);
|
||||
await tool('pm', 'intake_propose', { request: s.request, revision: s.revision, op: 'existing-plan', source_project: project, project, name: entry.name, tracker_project: entry.tracker.project });
|
||||
return state();
|
||||
}
|
||||
return s;
|
||||
}
|
||||
if (s.phase === 'bootstrap') { await bus.requestLifecycle.resume('demo', s.request); continue; }
|
||||
if (s.phase === 'discovery') {
|
||||
if (s.mode === 'guided' && !s.question) {
|
||||
await tool('pm', 'intake_question', { request: s.request, revision: s.revision, op: 'platform-question', text: 'Use an offline browser and allow a cue after Start?', options: [{ key: 'offline', text: 'Offline browser with a cue' }] });
|
||||
return state();
|
||||
}
|
||||
if (s.question?.answer === null) return s;
|
||||
await draft(s); continue;
|
||||
}
|
||||
if (s.phase === 'prd-review') {
|
||||
if (s.mode === 'guided') return s;
|
||||
await tool('cto', 'intake_status', { request: s.request, path: s.candidate.prd_path });
|
||||
await tool('cto', 'intake_status', { request: s.request, path: s.candidate.requirements[0].path });
|
||||
await tool('cto', 'resolve_decision', { id: s.wait.subject, choice: 'yes', note: 'The exact agent proposal fits the confirmed original offline timer intent.' });
|
||||
await bus.requestLifecycle.resume('demo', s.request); continue;
|
||||
}
|
||||
if (s.phase === 'planning') {
|
||||
await adapter.reconcileCreates('demo');
|
||||
let task = rows('task.created').find(e => e.body.request === s.request);
|
||||
if (!task) {
|
||||
try {
|
||||
await tool('pm', 'task_create', { title: 'Implement reviewed offline Pomodoro', request: s.request, requirement: REQUIREMENT,
|
||||
project_key: s.project, requirement_snapshot: s.snapshot, op: 'pomodoro-' + s.request });
|
||||
} catch (e) { if (/task-create-uncertain|tracker-|write-uncertain/.test(e.message)) return state(); throw e; }
|
||||
task = rows('task.created').find(e => e.body.request === s.request);
|
||||
}
|
||||
if (!rows('task.assigned', task.subject).length) await tool('pm', 'task_assign', { task_ref: task.subject, role: 'coder' });
|
||||
await tool('pm', 'advance_request', { request: s.request, revision: s.revision, op: 'implement' });
|
||||
await refreshGit(); await launch('coder'); await launch('reviewer'); continue;
|
||||
}
|
||||
if (s.phase === 'implementation') {
|
||||
await refreshGit(); await launch('coder'); await launch('reviewer');
|
||||
const task = s.tasks[0], clone = join(roles.coder.workspace, s.project), path = `products/${s.project}/pomodoro.html`;
|
||||
const boundHTML = HTML.replace('</title>', `</title><meta name="mosaic-snapshot" content="${s.snapshot}"><meta name="mosaic-request" content="${s.request}">`);
|
||||
const expected = journal.intent('demo', s.request).text.includes('keyboard shortcut')
|
||||
? boundHTML.replace('</script>', "document.addEventListener('keydown',e=>{if(e.key.toLowerCase()==='r')document.querySelector('#reset').click();});</script>") : boundHTML;
|
||||
if (!existsSync(join(clone, path)) || readFileSync(join(clone, path), 'utf8') !== expected) {
|
||||
git(clone, 'checkout', '-qb', 'pomodoro-' + s.request.slice(0, 8));
|
||||
mkdirSync(dirname(join(clone, path)), { recursive: true }); writeFileSync(join(clone, path), expected);
|
||||
writeFileSync(join(clone, dirname(path), 'pomodoro.test.mjs'), TIMER_TEST);
|
||||
verifyHTML(readFileSync(join(clone, path), 'utf8'));
|
||||
const tested = await run(process.execPath, ['--test', join(dirname(path), 'pomodoro.test.mjs')], { cwd: clone, env: { PATH: process.env.PATH, TMPDIR: process.env.TMPDIR } });
|
||||
appendFileSync(join(root, 'coder-tests.log'), tested.stdout);
|
||||
git(clone, 'add', '--', dirname(path)); git(clone, 'commit', '--author', 'coder <[email protected]>', '-qm', 'Build the approved offline Pomodoro');
|
||||
}
|
||||
const commit = git(clone, 'rev-parse', 'HEAD'), branch = git(clone, 'branch', '--show-current');
|
||||
const pushed = rows('action.allowed', task).find(e => e.body.action === 'git.push.working' && e.body.commit === commit);
|
||||
if (!pushed) await tool('coder', 'push_working', { task_ref: task, project: s.project, branch });
|
||||
if (!rows('review.requested', task).some(e => e.body.commit === commit)) await tool('coder', 'request_review', { task_ref: task, commit, to: 'reviewer' });
|
||||
await tool('pm', 'advance_request', { request: s.request, revision: s.revision, op: 'review' }); continue;
|
||||
}
|
||||
if (s.phase === 'review') {
|
||||
await refreshGit(); await launch('reviewer');
|
||||
const task = s.tasks[0], review = rows('review.requested', task).at(-1);
|
||||
if (!rows('review.verdict', task).some(e => e.body.request === review.id)) {
|
||||
const fetched = await tool('reviewer', 'fetch_review', { task_ref: task, request: review.id });
|
||||
const path = `products/${s.project}/pomodoro.html`;
|
||||
const bytes = git(fetched.clone, 'show', `${fetched.commit}:${path}`);
|
||||
const results = verifyHTML(bytes);
|
||||
git(fetched.clone, 'checkout', '--detach', fetched.commit);
|
||||
const tested = await run(process.execPath, ['--test', join(dirname(path), 'pomodoro.test.mjs')], { cwd: fetched.clone, env: { PATH: process.env.PATH, TMPDIR: process.env.TMPDIR } });
|
||||
appendFileSync(join(root, 'reviewer-tests.log'), tested.stdout);
|
||||
const verification = await tool('reviewer', 'write_record', { project: s.project, path: 'docs/verification-' + s.request.slice(0, 8) + '.md',
|
||||
record: JSON.stringify({ version: 1, task_ref: task, review_request: review.id, commit: fetched.commit, results }) });
|
||||
await tool('reviewer', 'review_verdict', { task_ref: task, request: review.id, verdict: 'approve', body: JSON.stringify({ verification_record: { path: verification.path, commit: verification.commit }, results }) });
|
||||
}
|
||||
await tool('pm', 'advance_request', { request: s.request, revision: s.revision, op: 'deliver' }); continue;
|
||||
}
|
||||
if (s.phase === 'delivery') {
|
||||
const task = s.tasks[0], verdict = rows('review.verdict', task).at(-1), record = JSON.parse(store.get('SELECT body FROM messages WHERE id=?', verdict.body.message).body.split('\n\n').slice(1).join('\n\n')).verification_record;
|
||||
if (!rows('task.closed', task).length) await tool('pm', 'task_close', { task_ref: task, verdict: verdict.id });
|
||||
await tool('pm', 'deliver_request', { request: s.request, revision: s.revision, op: 'delivery', task_ref: task, verdict: verdict.id,
|
||||
path: `products/${s.project}/pomodoro.html`, verification_record: record }); continue;
|
||||
}
|
||||
if (s.phase === 'complete') {
|
||||
const messages = store.all('SELECT * FROM messages WHERE business=? ORDER BY seq', 'demo');
|
||||
if (!messages.some(m => m.from_role === 'chief' && m.to_role === 'assistant' && m.in_reply_to === s.source_message)) {
|
||||
await tool('chief', 'send_message', { to: 'assistant', in_reply_to: s.source_message,
|
||||
body: 'Reviewed local HTML is ready: ' + JSON.stringify(s.delivery_evidence) });
|
||||
}
|
||||
if (!messages.some(m => m.from_role === 'assistant' && m.to_role === 'human' && m.in_reply_to === s.source_message)) {
|
||||
await tool('assistant', 'send_message', { to: 'human', in_reply_to: s.source_message, body: 'Download the reviewed Pomodoro HTML. ' + s.delivery });
|
||||
}
|
||||
await bus.requestLifecycle.recover(); return state();
|
||||
}
|
||||
throw new Error('unexpected phase ' + s.phase);
|
||||
}
|
||||
throw new Error('fixture made no bounded progress');
|
||||
}
|
||||
const handlers = {
|
||||
start: ({ text, attach }) => startRequest(text, attach),
|
||||
drive,
|
||||
arm: ({ point }) => { armed = point; return true; },
|
||||
console: async ({ verb, args, subject }) => {
|
||||
const cap = bus.broker.bindConsole({ business: 'demo', human: 'jason', subject });
|
||||
try {
|
||||
const result = await bus.broker.request(cap, { verb, args });
|
||||
if (verb === 'decision.resolve.console') await checkpoint('selection-resolution');
|
||||
return result;
|
||||
} finally { bus.broker.disconnect(cap); }
|
||||
},
|
||||
human: ({ verb, args }) => call('human', verb, args),
|
||||
close: async () => { for (const server of servers) await new Promise(resolve => server.close(resolve)); await socket?.close(); db.close(); await bus.close(); return true; },
|
||||
};
|
||||
process.on('message', async m => {
|
||||
try { const value = await handlers[m.op](m.args ?? {}); process.send({ id: m.id, value }); if (m.op === 'close') process.disconnect(); }
|
||||
catch (e) { process.send({ id: m.id, error: e.code ?? e.message, detail: e.stack }); }
|
||||
});
|
||||
process.send({ ready: true });
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// Row 194 (#1692, D1.1, T2-T5, V5): the launcher half of the lifecycle verbs. The broker records
|
||||
// the human's launch, end, regenerate and delete as requests (bus lifecycle-verbs.test.mjs), and
|
||||
// the launcher's lifecycle pass takes each one, acts on it and closes it. Real runners in the bwrap
|
||||
@@ -82,6 +83,7 @@ async function start(t, { f, adapter }, options = {}) {
|
||||
// The real host makes bus-host/ (0700). A --no-launch launcher's read-only registry doesn't.
|
||||
mkdirSync(join(f.dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
...requestHostOps(runtime),
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// #653 isolated owned fixture: real launcher/runtime/runner and fake loopback management HTTP.
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
@@ -52,7 +53,9 @@ export async function managedLaunchFixture(t,{proxy=true,launches=true,refusePro
|
||||
stderr=process.stderr.write;process.stderr.write=function(chunk,...args){if(String(chunk).startsWith('model-proxy: ')){proxyReceipts.push(String(chunk));const callback=args.find(a=>typeof a==='function');callback?.();return true;}return stderr.call(this,chunk,...args);};
|
||||
bus=await startBroker(boot);closers.push(()=>bus.close());
|
||||
const hooks=[];let expectedKeyId;
|
||||
const requestOps=requestHostOps(bus);
|
||||
const actualOp=async m=>{
|
||||
if(Object.hasOwn(requestOps,m.op))return requestOps[m.op](m);
|
||||
switch(m.op){
|
||||
case 'modelPreflight':return bus.modelPreflight(m);
|
||||
case 'modelStall':return bus.modelStall(m);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// Keep trusted in-process launcher fixtures on the production request-host protocol.
|
||||
// Every entry forwards to the actual runtime; there is no unconditional ready/allow.
|
||||
export const requestHostOps = runtime => ({
|
||||
'request.admission': m => runtime.admission({ business: m.business, project: m.project }),
|
||||
'request.launches': m => runtime.requestLaunches(m.business),
|
||||
'request.launchResult': m => runtime.requestLaunchResult(m.record),
|
||||
'request.release': m => runtime.requestRelease(m.cap, m.record),
|
||||
'request.chief': m => runtime.requestChief(m.business),
|
||||
'request.manifest': m => runtime.requestManifest(m.record),
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { fork } from 'node:child_process';
|
||||
import { once } from 'node:events';
|
||||
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, existsSync, readdirSync, mkdtempSync, rmSync, lstatSync, unlinkSync, statSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { fixture, tmp, withoutHostCredentials } from './helpers.mjs';
|
||||
import { git } from '../../project/tests/prd-fixture.mjs';
|
||||
import { FakeVikunja } from '../../tasks/src/fake.mjs';
|
||||
import { SCOPES } from '../../tasks/tests/world.mjs'; // Scopes only, never AUTHORITY or world().
|
||||
import { createRequestStore } from '../../bus/src/request-store.mjs';
|
||||
import { startServer } from '../../webui/src/serve.mjs';
|
||||
import { createSso, SESSION_COOKIE } from '../../webui/src/sso.mjs';
|
||||
import { fakeOidc } from '../../webui/tests/helpers/fake-oidc.mjs';
|
||||
import { browser } from '../../webui/tests/browser.mjs';
|
||||
import { hostStatus } from '../src/host.mjs';
|
||||
import { views } from '../../bus/src/views.mjs';
|
||||
import { BusReadError } from '../../webui/src/bus.mjs';
|
||||
|
||||
const AGENT = fileURLToPath(new URL('./fixtures/request-agent.mjs', import.meta.url));
|
||||
export const ORIGIN = 'https://lifecycle.console.test', HOST = new URL(ORIGIN).host;
|
||||
const closeServer = server => new Promise(resolve => { server.close(resolve); server.closeIdleConnections(); });
|
||||
|
||||
export async function lifecycleFixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'life-')), original = JSON.parse(readFileSync(new URL('./fixtures/pomodoro-request.json', import.meta.url)));
|
||||
let web, sso, op, service, child;
|
||||
t.after(async () => {
|
||||
if (process.env.MOSAIC_LIFECYCLE_EVIDENCE && existsSync(join(root, 'scenario.json'))) {
|
||||
const dest = process.env.MOSAIC_LIFECYCLE_EVIDENCE; mkdirSync(dest, { recursive: true });
|
||||
const file = join(dest, basename(root) + '.json');
|
||||
writeFileSync(file, JSON.stringify(await evidence(), null, 2)+'\n', { flag: 'wx' });
|
||||
t.diagnostic('lifecycle evidence: ' + file);
|
||||
}
|
||||
if (web) await closeServer(web);
|
||||
sso?.close(); await op?.close();
|
||||
if (child && child.exitCode === null && !child.signalCode) {
|
||||
const gone = once(child, 'exit'); process.kill(-child.pid, 'SIGKILL'); await gone;
|
||||
}
|
||||
await service?.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const fake = new FakeVikunja(), owner = fake.user('owner'), bots = {};
|
||||
for (let n = 2; n <= 9; n++) {
|
||||
const role = ({ 3: 'pm', 4: 'cto', 5: 'coder', 6: 'reviewer', 9: 'sync' })[n];
|
||||
const id = fake.user(role ?? 'unused-' + n, { owner }); if (role) bots[role] = id;
|
||||
}
|
||||
const board = fake.project('Factory', owner), productBoard = fake.project('Products', owner, { parent: board });
|
||||
for (const id of [board, productBoard]) { fake.install(id); for (const [role, bot] of Object.entries(bots)) fake.share(id, bot, role === 'sync' ? 0 : 1); }
|
||||
service = await fake.listen();
|
||||
const f = fixture(root, 'demo', doc => {
|
||||
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: 'glm-5.3-flash', thinking: 'high' } };
|
||||
for (const r of Object.values(doc.roles)) r.vars = { ...r.vars, harness: 'pi', model: 'glm-5.3-flash', thinking: 'high' };
|
||||
doc.launch.instances = ['pm','cto','coder','reviewer'];
|
||||
doc.launch.limits.models.zai['glm-5.3-flash'] = 8;
|
||||
doc.vars['tracker.baseUrl'] = service.url;
|
||||
for (const role of Object.keys(bots)) {
|
||||
const target = role === 'sync' ? doc.tracker.sync : doc.roles[role];
|
||||
target.credentials.vikunja = { env: 'FIXTURE_VK_' + role.toUpperCase(), expires: '2099-01-01' };
|
||||
}
|
||||
return doc;
|
||||
});
|
||||
withoutHostCredentials(f.env);
|
||||
for (const role of Object.keys(bots)) f.env['FIXTURE_VK_' + role.toUpperCase()] = fake.token(bots[role], role === 'sync'
|
||||
? { ...SCOPES.sync, projects: [...SCOPES.sync.projects, 'read_all'] } : role === 'pm' ? SCOPES.pm : SCOPES.worker);
|
||||
f.env.REQUEST_FIXTURE_ROOT = root;
|
||||
const source = join(root, 'project'); mkdirSync(join(source, '.mosaic'), { recursive: true });
|
||||
writeFileSync(join(source, '.mosaic/project.json'), JSON.stringify({ projectVersion: 1, id: 'stack', vars: { 'tracker.project': board, 'tracker.descendants': true } }));
|
||||
git(source, 'init', '-qb', 'main'); git(source, 'add', '-A'); git(source, 'commit', '-qm', 'Isolated factory');
|
||||
|
||||
let next = 0, stopped = false, pausedPoint = null, killing = null, killResolve, hide = false;
|
||||
const waiting = new Map();
|
||||
const rpc = (op, args = {}) => new Promise((resolve, reject) => {
|
||||
if (!child?.connected) return reject(new Error('fixture runtime not connected'));
|
||||
const id = ++next, timer = setTimeout(() => { waiting.delete(id); reject(new Error('fixture RPC timeout: ' + op)); }, 120000);
|
||||
waiting.set(id, { resolve, reject, timer }); child.send({ id, op, args });
|
||||
});
|
||||
const stopHard = async point => {
|
||||
pausedPoint = point;
|
||||
if (!child || child.exitCode !== null || child.signalCode) return;
|
||||
const gone = once(child, 'exit');
|
||||
process.kill(-child.pid, 'SIGKILL'); await gone;
|
||||
killResolve?.(point); killResolve = null;
|
||||
};
|
||||
const rawFetch = fake.fetch;
|
||||
fake.fetch = async (url, init) => {
|
||||
const path = new URL(url).pathname;
|
||||
if (hide && /\/projects\/\d+\/tasks$/.test(path) && new URL(url).searchParams.get('expand') !== 'comment_count' && (init?.method ?? 'GET') === 'GET') return new Response('{}', { status: 503 });
|
||||
const result = await rawFetch(url, init);
|
||||
if (killing === 'tracker-post' && init?.method === 'POST' && /\/projects\/\d+\/tasks$/.test(path)) {
|
||||
killing = null; await stopHard('tracker-post');
|
||||
}
|
||||
return result;
|
||||
};
|
||||
async function boot() {
|
||||
stopped = false;
|
||||
child = fork(AGENT, ['--runtime'], { env: f.env, stdio: ['ignore','pipe','pipe','ipc'], detached: true });
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
child.on('message', m => {
|
||||
if (m.ready) resolve();
|
||||
else if (m.checkpoint) stopHard(m.checkpoint).catch(reject);
|
||||
else if (waiting.has(m.id)) {
|
||||
const p = waiting.get(m.id); waiting.delete(m.id); clearTimeout(p.timer);
|
||||
m.error ? p.reject(Object.assign(new Error(m.error), { code: m.error, detail: m.detail })) : p.resolve(m.value);
|
||||
}
|
||||
});
|
||||
child.once('exit', (code, signal) => {
|
||||
for (const p of waiting.values()) { clearTimeout(p.timer); p.reject(new Error('fixture runtime exited: ' + (signal ?? code))); } waiting.clear();
|
||||
if (code || signal) reject(new Error('fixture runtime boot/exit: ' + (signal ?? code) + '\n' + readFileSync(join(root, 'runtime.log'), 'utf8')));
|
||||
});
|
||||
});
|
||||
for (const stream of [child.stdout, child.stderr]) stream.on('data', bytes => appendFileSync(join(root, 'runtime.log'), bytes));
|
||||
writeFileSync(join(root, 'runtime.log'), '', { flag: 'a' });
|
||||
await ready;
|
||||
}
|
||||
op = await fakeOidc();
|
||||
const issuer = op.issuer('fixture');
|
||||
sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId,
|
||||
clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] },
|
||||
{ secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
|
||||
const transport = async (verb, args, ctx) => {
|
||||
try { return await rpc('console', { verb, args, subject: ctx.subject }); }
|
||||
catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); }
|
||||
};
|
||||
const human = (verb, args = {}) => rpc('human', { verb, args });
|
||||
const bus = views({ call: human });
|
||||
const chatNames = { projects: 'project.list', threads: 'thread.list', read: 'thread.messages', sessions: 'session.list', instances: 'thread.instances',
|
||||
createProject: 'project.create', createThread: 'thread.create', open: 'thread.open', send: 'thread.send' };
|
||||
const chat = Object.fromEntries(Object.entries(chatNames).map(([name, verb]) => [name, args => human(verb, args)]));
|
||||
web = await startServer({ port: 0, sso, send: args => human('message.send', args), bus, chat,
|
||||
requestRead: transport, requestWrite: transport, decisionWrite: transport, log: () => {} });
|
||||
const port = web.address().port;
|
||||
function http(path, { method = 'GET', body, cookie, headers = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const raw = body === undefined ? undefined : JSON.stringify(body);
|
||||
if (method === 'POST') appendFileSync(join(root, 'owner-actions.jsonl'), JSON.stringify({ method, path })+'\n');
|
||||
const req = httpRequest({ host: '127.0.0.1', port, path, method, headers: { host: HOST, ...(cookie ? { cookie } : {}),
|
||||
...(raw === undefined ? {} : { 'content-length': Buffer.byteLength(raw), 'content-type': 'application/json', origin: ORIGIN, 'sec-fetch-site': 'same-origin' }), ...headers } }, res => {
|
||||
const parts = []; res.on('data', part => parts.push(part)); res.on('end', () => {
|
||||
const text = Buffer.concat(parts).toString();
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: (res.headers['content-type'] ?? '').startsWith('application/json') ? JSON.parse(text) : text });
|
||||
});
|
||||
}); req.on('error', reject); req.setTimeout(120000, () => req.destroy(new Error('fixture HTTP timeout ' + path))); req.end(raw);
|
||||
});
|
||||
}
|
||||
const login = await http('/auth/login');
|
||||
const loginCookie = [login.headers['set-cookie']].flat()[0].split(';')[0];
|
||||
const grant = op.grant(issuer, login.headers.location, { sub: 'owner' });
|
||||
const query = new URLSearchParams({ code: grant.code, state: new URL(login.headers.location).searchParams.get('state') });
|
||||
const signed = await http('/auth/callback?' + query, { cookie: loginCookie });
|
||||
const cookie = [signed.headers['set-cookie']].flat().find(v => v.startsWith(SESSION_COOKIE + '=') && !v.includes('Max-Age=0')).split(';')[0];
|
||||
const meta = () => JSON.parse(readFileSync(join(root, 'scenario.json')));
|
||||
const checked = response => { assert.equal(response.status, 200, JSON.stringify(response.body) + (existsSync(join(root, 'console-errors.jsonl')) ? '\n' + readFileSync(join(root, 'console-errors.jsonl'), 'utf8') : '')); return response.body; };
|
||||
const get = async path => checked(await http(path, { cookie }));
|
||||
const post = async (path, body) => checked(await http(path, { cookie, method: 'POST', body }));
|
||||
const current = () => get('/api/requests/' + meta().request);
|
||||
const decide = async (id, choice) => {
|
||||
const preview = await post(`/api/decisions/${id}/confirmation`, { choice });
|
||||
return post(`/api/decisions/${id}/resolve`, { choice, confirmation: preview.confirmation });
|
||||
};
|
||||
const continueRun = () => rpc('drive');
|
||||
await boot();
|
||||
async function evidence() {
|
||||
const m = meta(), db = new DatabaseSync(join(f.dataRoot, 'bus/bus.sqlite'), { readOnly: true });
|
||||
try {
|
||||
const store = { all: (sql, ...a) => db.prepare(sql).all(...a), get: (sql, ...a) => db.prepare(sql).get(...a) };
|
||||
const journal = createRequestStore(store), s = journal.readRequest('demo', m.request);
|
||||
const history = Object.fromEntries(['events','request_events','request_effects','request_snapshots','request_snapshot_events'].map(table => [table, store.all(`SELECT * FROM ${table} ORDER BY ${table === 'request_snapshots' ? 'id' : 'seq'}`)]));
|
||||
const events = history.events.map(e => ({ ...e, body: JSON.parse(e.body) }));
|
||||
const tasks = events.filter(e => e.kind === 'task.created' && e.body.request === m.request);
|
||||
const task = tasks[0]?.subject;
|
||||
const sessions = readdirSync(join(f.dataRoot, 'launches/demo')).sort((a,b)=>statSync(join(f.dataRoot,'launches/demo',a,'session.json')).mtimeMs-statSync(join(f.dataRoot,'launches/demo',b,'session.json')).mtimeMs).map(id => JSON.parse(readFileSync(join(f.dataRoot, 'launches/demo', id, 'session.json'))));
|
||||
const active = role => sessions.findLast(s => s.instance === role);
|
||||
const artifact = s.delivery_evidence;
|
||||
let download = '';
|
||||
if (artifact) download = git(join(f.dataRoot, 'working/demo/timer.git'), 'show', `${artifact.commit}:${artifact.artifact.path}`);
|
||||
const source = store.get('SELECT body FROM messages WHERE id=?', s.source_message);
|
||||
const messages = store.all('SELECT * FROM messages ORDER BY seq');
|
||||
const creates = fake.requests.filter(r => r.method === 'POST' && /\/projects\/\d+\/tasks$/.test(r.path)).length;
|
||||
return { scenario: t.name, checkpoint: pausedPoint, request: m.request, state: s, history, tasks, events, source: { source_event: m.request, source_message: s.source_message, text: source.body }, original_quote: original, intent: journal.intent('demo', m.request).text,
|
||||
provenance: original.provenance, coderTests: existsSync(join(root, 'coder-tests.log')) ? readFileSync(join(root, 'coder-tests.log'), 'utf8') : '', reviewerTests: existsSync(join(root, 'reviewer-tests.log')) ? readFileSync(join(root, 'reviewer-tests.log'), 'utf8') : '', lockRecovery: existsSync(join(root, 'lock-recovery.jsonl')) ? readFileSync(join(root, 'lock-recovery.jsonl'), 'utf8') : '', projects: existsSync(join(f.dataRoot, 'products/demo')) ? readdirSync(join(f.dataRoot, 'products/demo')) : [],
|
||||
creates: Array.from({ length: creates }, (_, n) => fake.task(n + 1)).filter(t => t?.description.includes('request: ' + m.request)).length,
|
||||
allCreates: creates, deliveries: history.request_effects.filter(e => e.request === m.request && e.kind === 'delivery' && e.stage === 'committed'),
|
||||
activation: s.snapshot ? history.request_snapshot_events.find(e => e.snapshot === s.snapshot && e.kind === 'activated') : null,
|
||||
snapshot: s.snapshot ? journal.snapshot(s.snapshot) : null, download, commit: task ? events.findLast(e => e.subject === task && e.body.action === 'git.push.working')?.body.commit : null,
|
||||
coder: active('coder'), reviewer: active('reviewer'), manifests: Object.fromEntries(['pm','coder','reviewer'].map(role => [role, active(role)?.manifest ?? []])),
|
||||
uncertain: events.filter(e => /uncertain/.test(JSON.stringify(e.body))),
|
||||
userRelays: messages.filter(m => m.from_role === 'human' && /relay|forward this|run this command/i.test(m.body)).length,
|
||||
userShellCommands: (existsSync(join(root, 'owner-actions.jsonl')) ? readFileSync(join(root, 'owner-actions.jsonl'), 'utf8') : '').trim().split('\n').filter(Boolean).map(JSON.parse).filter(a => /shell|command|task-create/.test(a.path)).length,
|
||||
ownerSelections: events.filter(e => e.kind === 'human.input' && e.body.kind === 'answer' && e.body.decision === s.selection?.decision).length,
|
||||
notification: messages.find(m => m.from_role === 'assistant' && m.to_role === 'human' && m.in_reply_to === s.source_message),
|
||||
};
|
||||
} finally { db.close(); }
|
||||
}
|
||||
return {
|
||||
root, fake, http, get, post, current, decide, evidence,
|
||||
start: () => rpc('start', { text: original.text }),
|
||||
continue: continueRun,
|
||||
async select(choice) { const s = await current(); await decide(s.selection.decision, choice); return continueRun(); },
|
||||
async answer(text) { const s = await current(); await post(`/api/requests/${s.request}/answer`, { revision: s.revision, question: s.question.id, op: 'owner-answer', text }); return continueRun(); },
|
||||
async approve(choice) { const s = await current(); await decide(s.wait.subject, choice); return continueRun(); },
|
||||
async cancel() { const s = await current(); await post(`/api/requests/${s.request}/cancel`, { revision: s.revision, op: 'owner-cancel' }); return continueRun(); },
|
||||
async mode(mode) { const s = await current(); await post(`/api/requests/${s.request}/mode`, { revision: s.revision, mode_revision: s.mode_revision, mode, op: 'owner-mode' }); return continueRun(); },
|
||||
async attach({ revise }) {
|
||||
await rpc('start', { text: revise ? original.text + ' Add an R keyboard shortcut to reset.' : original.text, attach: true });
|
||||
let s = await current(); await decide(s.selection.decision, 'existing'); await continueRun();
|
||||
s = await current(); const entries = await get(`/api/requests/${s.request}/projects`); assert.ok(entries.some(e => e.project === 'timer'));
|
||||
await post(`/api/requests/${s.request}/attach`, { revision: s.revision, project: 'timer', op: 'owner-pick' });
|
||||
await continueRun(); s = await current(); await decide(s.selection.decision, 'confirm'); return continueRun();
|
||||
},
|
||||
async kill(point) {
|
||||
killing = point; const killed = new Promise(resolve => { killResolve = resolve; });
|
||||
if (point !== 'tracker-post') await rpc('arm', { point });
|
||||
const s = await current();
|
||||
const work = decide(s.selection.decision, 'autonomous').then(continueRun).catch(() => {});
|
||||
await killed; await work; assert.equal(pausedPoint, point);
|
||||
},
|
||||
async restart() {
|
||||
if (child?.connected) { await rpc('close'); if (child.connected) child.disconnect(); }
|
||||
const lock = join(f.dataRoot, 'bus/writer.lock');
|
||||
if (existsSync(lock)) {
|
||||
// Documented operator recovery, confined to this fixture's exact reaped child.
|
||||
const status = hostStatus(f.dataRoot), before = lstatSync(lock);
|
||||
assert.equal(status.writerLock.pid, child.pid); assert.equal(status.writerLock.live, false);
|
||||
assert.equal(before.uid, process.getuid()); assert.ok(before.isFile());
|
||||
const after = lstatSync(lock); assert.equal(after.ino, before.ino); assert.equal(after.dev, before.dev);
|
||||
appendFileSync(join(root, 'lock-recovery.jsonl'), JSON.stringify({ pid: child.pid, verified_dead: true, inode: before.ino, action: 'remove-owned-stale-lock-and-socket' })+'\n');
|
||||
unlinkSync(lock);
|
||||
const socket = join(f.dataRoot, 'bus/broker.sock'); if (existsSync(socket)) { assert.ok(lstatSync(socket).isSocket()); unlinkSync(socket); }
|
||||
}
|
||||
await boot(); return continueRun();
|
||||
},
|
||||
hideTrackerOutcome() { hide = true; }, showTrackerOutcome() { hide = false; },
|
||||
async download() { const s = await current(); return get(`/api/requests/${s.request}/artifact/${s.delivery}`); },
|
||||
consoleBrowser: t => consoleBrowser(t, { port, cookie, current, source: () => meta(), decide, get, recordOwner: path => appendFileSync(join(root, 'owner-actions.jsonl'), JSON.stringify({ method: 'POST', path })+'\n') }),
|
||||
};
|
||||
}
|
||||
|
||||
async function consoleBrowser(t, f) {
|
||||
const b = await browser(); t.after(() => b.close());
|
||||
b.on('Fetch.requestPaused', ({ requestId, request: incoming }) => {
|
||||
const url = new URL(incoming.url);
|
||||
if (incoming.method === 'POST') f.recordOwner(url.pathname);
|
||||
const req = httpRequest({ host: '127.0.0.1', port: f.port, path: url.pathname + url.search, method: incoming.method,
|
||||
headers: { ...incoming.headers, host: HOST, cookie: f.cookie, origin: ORIGIN, 'sec-fetch-site': 'same-origin' } }, res => {
|
||||
const parts = []; res.on('data', part => parts.push(part)); res.on('end', () => {
|
||||
const responseHeaders = Object.entries(res.headers).filter(([name]) => !['connection','keep-alive','transfer-encoding'].includes(name))
|
||||
.flatMap(([name, value]) => (Array.isArray(value) ? value : [value]).map(value => ({ name, value: String(value) })));
|
||||
b.call('Fetch.fulfillRequest', { requestId, responseCode: res.statusCode, responseHeaders, body: Buffer.concat(parts).toString('base64') }).catch(() => {});
|
||||
});
|
||||
}); req.on('error', () => b.call('Fetch.failRequest', { requestId, errorReason: 'Failed' }).catch(() => {})); req.end(incoming.postData);
|
||||
});
|
||||
await b.call('Fetch.enable', { patterns: [{ urlPattern: ORIGIN + '/*', requestStage: 'Request' }] });
|
||||
const s = await f.current(); await b.navigate(`${ORIGIN}/#/inbox/${s.selection.decision}`);
|
||||
const wait = expr => b.evaluate(`(async()=>{for(let n=0;n<200;n++){if(${expr})return;await new Promise(r=>setTimeout(r,50));}throw new Error('browser wait: '+${JSON.stringify(expr)})})()`);
|
||||
const external = [];
|
||||
return {
|
||||
async choose(choice) {
|
||||
await wait('document.querySelector("#decide-section [data-decide=option-0]")');
|
||||
const index = ['autonomous','guided','existing'].indexOf(choice);
|
||||
await b.evaluate(`document.querySelector('[data-decide=option-${index}]').click()`);
|
||||
await wait('document.querySelector("[data-decide=confirm]")');
|
||||
await b.evaluate('document.querySelector("[data-decide=confirm]").click()');
|
||||
await wait('document.querySelector("#decide-status")?.textContent.startsWith("Recorded")');
|
||||
},
|
||||
async offline(html) {
|
||||
await b.call('Fetch.disable');
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: `window.__now=1000;Date.now=()=>window.__now;window.__ticks=[];window.setInterval=fn=>{window.__ticks.push(fn);return window.__ticks.length;};window.__sounds=0;window.AudioContext=class{currentTime=0;destination={};resume(){}createOscillator(){return{connect(){},frequency:{value:0},start(){window.__sounds++},stop(){}}}createGain(){return{connect(){},gain:{value:0}}}};` });
|
||||
await b.call('Network.enable'); await b.call('Network.emulateNetworkConditions', { offline: true, latency: 0, downloadThroughput: 0, uploadThroughput: 0 });
|
||||
b.on('Network.requestWillBeSent', ({ request }) => { if (/^https?:/.test(request.url)) external.push(request.url); });
|
||||
const path = join(process.env.TMPDIR, 'downloaded-pomodoro-' + process.pid + '.html'); writeFileSync(path, html); await b.navigate('file://' + path);
|
||||
},
|
||||
clock: () => b.evaluate('document.querySelector("#clock").textContent'), phase: () => b.evaluate('document.querySelector("#phase").textContent'),
|
||||
cues: () => b.evaluate('window.__sounds'), externalRequests: async () => external,
|
||||
async start() { await b.call('Runtime.evaluate', { expression: 'document.querySelector("#start").click()', userGesture: true }); },
|
||||
async pause() { await b.evaluate('document.querySelector("#pause").click()'); },
|
||||
async reset() { await b.evaluate('document.querySelector("#reset").click()'); },
|
||||
async advance(ms) { await b.evaluate(`window.__now+=${ms};window.__ticks.forEach(fn=>fn());true`); },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const live = await import('../live/request-lifecycle.mjs').catch(e => {
|
||||
if (e.code === 'ERR_MODULE_NOT_FOUND' && e.message.includes('live/request-lifecycle.mjs')) return {};
|
||||
throw e;
|
||||
});
|
||||
const args = ['--instance','pomo797','--config','/isolated/config.json','--business','demo','--owner','jason','--evidence','/evidence','--request-file','/request.json','--authorize-isolated-run'];
|
||||
test('live runner requires an explicit named isolated instance and owner authorization before reading config', () => {
|
||||
assert.equal(typeof live.parseOptions, 'function');
|
||||
assert.throws(() => live.parseOptions([]), /instance/);
|
||||
assert.throws(() => live.parseOptions(args.filter(a => a !== '--authorize-isolated-run')), /authorization/);
|
||||
for (const name of ['dev','production','prod','../live','pomo-797']) assert.throws(() => live.parseOptions(args.map(a => a === 'pomo797' ? name : a)), /instance/);
|
||||
assert.equal(live.parseOptions(args).instance, 'pomo797');
|
||||
});
|
||||
test('live preflight refuses a production instance, wrong marker/owner, unavailable host or missing factory staff', () => {
|
||||
assert.equal(typeof live.checkResources, 'function');
|
||||
const options = live.parseOptions(args);
|
||||
const input = { options, system: { environment: 'development', dataRoot: '/isolated/data' }, marker: { instanceVersion: 1, name: options.instance },
|
||||
business: { id: 'demo', human: 'jason', projects: { factory: { root: '/isolated/factory' } }, roles: { pm: { definition: 'pm' }, cto: { definition: 'cto' }, chief: { definition: 'chief-of-staff' }, coder: { definition: 'coder' }, reviewer: { definition: 'reviewer' } },
|
||||
arbiters: { technical: 'cto' }, launch: { by: 'chief', instances: ['pm','cto','coder','reviewer'] } },
|
||||
tracker: { project: 1 }, host: { live: true } };
|
||||
assert.doesNotThrow(() => live.checkResources(input));
|
||||
assert.throws(() => live.checkResources({ ...input, system: { ...input.system, environment: 'production' } }), /isolated/);
|
||||
assert.throws(() => live.checkResources({ ...input, marker: { instanceVersion: 1, name: 'other' } }), /instance/);
|
||||
assert.throws(() => live.checkResources({ ...input, options: { ...options, owner: 'other' } }), /owner/);
|
||||
assert.throws(() => live.checkResources({ ...input, host: { live: false } }), /host/);
|
||||
assert.throws(() => live.checkResources({ ...input, tracker: null }), /tracker/);
|
||||
assert.throws(() => live.checkResources({ ...input, business: { ...input.business, launch: { by: 'chief', instances: ['pm','coder'] } } }), /staff/);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const fixtureModule = await import('./request-lifecycle-fixture.mjs').catch(e => {
|
||||
if (e.code === 'ERR_MODULE_NOT_FOUND' && e.message.includes('request-lifecycle-fixture.mjs')) return {};
|
||||
throw e;
|
||||
});
|
||||
async function fixture(t, options) {
|
||||
assert.equal(typeof fixtureModule.lifecycleFixture, 'function', 'Task 8 needs the process-backed acceptance fixture');
|
||||
return fixtureModule.lifecycleFixture(t, options);
|
||||
}
|
||||
const delivered = evidence => {
|
||||
assert.equal(evidence.state.phase, 'complete');
|
||||
assert.ok(evidence.state.delivery_evidence);
|
||||
assert.equal(evidence.tasks.length, 1);
|
||||
assert.equal(evidence.deliveries.length, 1);
|
||||
assert.equal(evidence.projects.length, 1);
|
||||
assert.equal(evidence.creates, 1);
|
||||
assert.equal(evidence.userRelays, 0);
|
||||
assert.equal(evidence.userShellCommands, 0);
|
||||
assert.ok(evidence.tasks[0].at > evidence.activation.at);
|
||||
assert.notEqual(evidence.coder.run, evidence.reviewer.run);
|
||||
assert.notEqual(evidence.coder.workspace, evidence.reviewer.workspace);
|
||||
assert.ok(evidence.manifests.coder.includes('push_working'));
|
||||
assert.ok(evidence.manifests.reviewer.includes('fetch_review'));
|
||||
assert.ok(!evidence.manifests.pm.includes('launch'));
|
||||
assert.equal(evidence.source.text, evidence.intent);
|
||||
assert.match(evidence.coderTests, /pass 1/);
|
||||
assert.match(evidence.reviewerTests, /pass 1/);
|
||||
assert.match(evidence.download, /25:00/);
|
||||
assert.doesNotMatch(evidence.download, /https?:\/\/|<script[^>]+src=|<link[^>]+href=/i);
|
||||
assert.equal(createHash('sha256').update(evidence.download).digest('hex'), evidence.state.delivery_evidence.artifact.sha256);
|
||||
assert.ok(evidence.notification, 'the chief and assistant completed the existing originating-session route');
|
||||
};
|
||||
|
||||
test('autonomous_original_request_delivers_offline_html', { timeout: 120000 }, async t => {
|
||||
const f = await fixture(t);
|
||||
await f.start();
|
||||
assert.equal((await f.evidence()).creates, 0);
|
||||
await f.select('autonomous');
|
||||
const e = await f.evidence(); delivered(e);
|
||||
const original = JSON.parse(readFileSync(new URL('./fixtures/pomodoro-request.json', import.meta.url)));
|
||||
assert.equal(e.source.text, original.text);
|
||||
assert.equal(createHash('sha256').update(original.text).digest('hex'), original.source_sha256);
|
||||
assert.equal(original.provenance.raw_runtime_event_exported, false);
|
||||
assert.deepEqual(e.provenance, original.provenance);
|
||||
assert.deepEqual(e.original_quote, original);
|
||||
assert.equal(e.ownerSelections, 1);
|
||||
});
|
||||
|
||||
test('guided_answers_and_exact_approval', { timeout: 120000 }, async t => {
|
||||
const f = await fixture(t); await f.start(); await f.select('guided');
|
||||
assert.equal((await f.evidence()).state.wait.kind, 'question');
|
||||
await f.answer('Use a visual and audible phase-end cue after Start.');
|
||||
const first = (await f.evidence()).state.candidate;
|
||||
assert.equal((await f.evidence()).creates, 0);
|
||||
await f.approve('no');
|
||||
const revised = (await f.evidence()).state.candidate;
|
||||
assert.notEqual(revised.records_commit, first.records_commit);
|
||||
await f.approve('yes');
|
||||
const e = await f.evidence(); delivered(e);
|
||||
assert.equal(e.snapshot.records_commit, revised.records_commit);
|
||||
assert.equal(e.snapshot.approval.via, 'console');
|
||||
});
|
||||
|
||||
for (const revise of [false, true]) test(`attach_existing_and_revise_missing_requirement (${revise ? 'revision' : 'matching snapshot'})`, { timeout: 180000 }, async t => {
|
||||
const f = await fixture(t); await f.start(); await f.select('autonomous');
|
||||
const original = await f.evidence();
|
||||
await f.attach({ revise });
|
||||
const e = await f.evidence(); delivered(e);
|
||||
assert.deepEqual(e.projects, original.projects);
|
||||
assert.equal(e.state.project, original.state.project);
|
||||
assert.equal(e.allCreates, 2);
|
||||
assert.equal(e.snapshot.id === original.snapshot.id, !revise);
|
||||
if (revise) assert.notEqual(e.snapshot.records_commit, original.snapshot.records_commit);
|
||||
});
|
||||
|
||||
test('cancellation during PRD approval preserves the product and never creates a task', { timeout: 120000 }, async t => {
|
||||
const f = await fixture(t); await f.start(); await f.select('guided'); await f.answer('Offline browser.');
|
||||
await f.cancel();
|
||||
const e = await f.evidence();
|
||||
assert.equal(e.state.phase, 'cancelled');
|
||||
assert.equal(e.creates, 0);
|
||||
assert.equal(e.deliveries.length, 0);
|
||||
assert.equal(e.projects.length, 1);
|
||||
await f.restart(); assert.equal((await f.evidence()).state.phase, 'cancelled');
|
||||
});
|
||||
|
||||
test('mode change during approval invalidates the old candidate before continuing', { timeout: 120000 }, async t => {
|
||||
const f = await fixture(t); await f.start(); await f.select('guided'); await f.answer('Offline browser.');
|
||||
const old = (await f.evidence()).state.wait.subject;
|
||||
await f.mode('autonomous');
|
||||
const e = await f.evidence(); delivered(e);
|
||||
assert.notEqual(e.snapshot.approval.decision, old);
|
||||
assert.equal(e.snapshot.mode_revision, 2);
|
||||
});
|
||||
|
||||
for (const point of ['selection-resolution', 'mapping', 'intent', 'prd-resolution', 'tracker-post', 'working-push', 'review-verdict', 'export-before-notification']) {
|
||||
test(`crash recovery after ${point}`, { timeout: 180000 }, async t => {
|
||||
const f = await fixture(t); await f.start();
|
||||
await f.kill(point);
|
||||
const before = await f.evidence();
|
||||
await f.restart();
|
||||
const after = await f.evidence(); delivered(after);
|
||||
for (const table of ['events','request_events','request_effects','request_snapshots','request_snapshot_events']) {
|
||||
for (const row of before.history[table]) assert.deepEqual(after.history[table].find(next => next.seq === row.seq && next.id === row.id), row, `${table} historical row`);
|
||||
}
|
||||
if (before.state.snapshot) assert.equal(after.state.snapshot, before.state.snapshot);
|
||||
if (before.state.delivery) assert.equal(after.state.delivery, before.state.delivery);
|
||||
if (before.commit) assert.equal(after.commit, before.commit);
|
||||
assert.equal(after.request, before.request);
|
||||
});
|
||||
}
|
||||
|
||||
test('an unprovable tracker create stays visibly uncertain without a duplicate or delivery claim', { timeout: 180000 }, async t => {
|
||||
const f = await fixture(t); await f.start(); await f.kill('tracker-post');
|
||||
f.hideTrackerOutcome();
|
||||
await f.restart();
|
||||
let e = await f.evidence();
|
||||
assert.equal(e.allCreates, 1);
|
||||
assert.notEqual(e.state.phase, 'complete');
|
||||
assert.equal(e.deliveries.length, 0);
|
||||
assert.ok(e.uncertain.length);
|
||||
f.showTrackerOutcome(); await f.continue();
|
||||
e = await f.evidence(); delivered(e);
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// Clear context (row 271, 264d, #1776, T4): the launcher half of D7 and D8. A clear of a running
|
||||
// session stops its drained run once, retires it on the run's own end, and starts the successor
|
||||
// through the same check()/start() as any session; a held start waits for capacity; an earlier
|
||||
@@ -82,6 +83,7 @@ async function start(t, { f, adapter }, { tickMs = 100, stopTimeoutMs } = {}) {
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker(boot);
|
||||
const ops = {
|
||||
...requestHostOps(runtime),
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// Thread sessions (row 121, #1615): the launcher starts a thread instance's
|
||||
// session when its thread has a pending message or was opened, stops it once
|
||||
// the thread is idle, and gives every run of a thread the thread's own store.
|
||||
@@ -83,6 +84,7 @@ async function start(t, { f, adapter }, { tickMs = 100, ops: replace } = {}) {
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker(boot);
|
||||
const ops = {
|
||||
...requestHostOps(runtime),
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// FED R4 (#1801, federation design 3.7): the launcher half of revocation. A thread whose user owner
|
||||
// lost the grant never starts, even with a message waiting across a restart; a running one gets
|
||||
// SIGTERM at once; and recover() still rebinds and ends an earlier host's run on such a thread
|
||||
@@ -87,6 +88,7 @@ async function start(t, { f, adapter, access }) {
|
||||
assert.equal(trackers, undefined);
|
||||
const runtime = await startBroker({ ...boot, access });
|
||||
const ops = {
|
||||
...requestHostOps(runtime),
|
||||
identity: (m) => runtime.identity(m.cap),
|
||||
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
const module = await import('../../cli/tests/request-lifecycle-fixture.mjs').catch(e => {
|
||||
if (e.code === 'ERR_MODULE_NOT_FOUND' && e.message.includes('request-lifecycle-fixture.mjs')) return {};
|
||||
throw e;
|
||||
});
|
||||
|
||||
test('signed-in Console buttons deliver the approved original request and downloaded timer works offline', { timeout: 180000 }, async t => {
|
||||
assert.equal(typeof module.lifecycleFixture, 'function', 'Task 8 needs the real lifecycle browser fixture');
|
||||
const f = await module.lifecycleFixture(t); await f.start();
|
||||
const page = await f.consoleBrowser(t);
|
||||
await page.choose('autonomous');
|
||||
await f.continue();
|
||||
const e = await f.evidence();
|
||||
assert.equal(e.state.phase, 'complete');
|
||||
const artifact = await f.download();
|
||||
assert.equal(artifact, e.download);
|
||||
await page.offline(artifact);
|
||||
assert.equal(await page.clock(), '25:00');
|
||||
assert.equal(await page.phase(), 'Work');
|
||||
assert.equal(await page.cues(), 0);
|
||||
await page.start(); await page.advance(1000);
|
||||
assert.equal(await page.clock(), '24:59');
|
||||
await page.pause(); await page.advance(10000);
|
||||
assert.equal(await page.clock(), '24:59');
|
||||
await page.start(); await page.advance(1499000);
|
||||
assert.equal(await page.phase(), 'Break');
|
||||
assert.equal(await page.clock(), '05:00');
|
||||
assert.equal(await page.cues(), 1);
|
||||
await page.advance(300000);
|
||||
assert.equal(await page.phase(), 'Work');
|
||||
assert.equal(await page.clock(), '25:00');
|
||||
assert.equal(await page.cues(), 2);
|
||||
await page.advance(7000); await page.reset();
|
||||
assert.equal(await page.clock(), '25:00');
|
||||
await page.advance(10000); assert.equal(await page.clock(), '25:00');
|
||||
assert.deepEqual(await page.externalRequests(), []);
|
||||
});
|
||||
Reference in new issue
Block a user