From 1c33c342bca8461a7330353da11d3d95c729109c Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Fri, 6 Nov 2026 01:51:08 -0600 Subject: [PATCH] fix(archive): fence new execution and preserve archived reads (#797) --- BUILD-LOG.md | 5 + docs/SESSIONS.md | 1 + packages/bus/README.md | 2 +- packages/bus/src/broker.mjs | 27 ++++-- packages/bus/src/request-bootstrap.mjs | 12 ++- packages/bus/src/request-continuation.mjs | 10 +- packages/bus/src/request-lifecycle.mjs | 33 ++++++- packages/bus/src/requirement-catalog.mjs | 4 +- packages/bus/src/runtime.mjs | 7 +- packages/bus/tests/prd-activation.test.mjs | 92 +++++++++++++++++++ packages/bus/tests/project-archive.test.mjs | 56 +++++++++++ packages/bus/tests/request-bootstrap.test.mjs | 28 ++++++ packages/bus/tests/request-console.test.mjs | 32 ++++++- .../bus/tests/request-continuation.test.mjs | 27 ++++++ packages/cli/src/launcher.mjs | 28 ++++-- packages/cli/tests/request-launch.test.mjs | 19 ++++ packages/cli/tests/thread-launch.test.mjs | 38 ++++++++ packages/webui/README.md | 2 + packages/webui/src/public/chat.js | 31 +++++-- packages/webui/src/public/project-archive.css | 21 ++++- packages/webui/src/public/project-archive.js | 44 +++++---- packages/webui/tests/chat-browser.test.mjs | 49 ++++++++++ packages/webui/tests/request-browser.test.mjs | 2 +- 23 files changed, 508 insertions(+), 62 deletions(-) diff --git a/BUILD-LOG.md b/BUILD-LOG.md index 0fc793057..2fe9b5db2 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -6193,3 +6193,8 @@ Before: clean isolated checkout at e9271841f3af23bf2a72aa17d9d67a1ec5abe017; no After: personally authored three-file skills/ms-ux-improvement coordinator package, complementary to existing frontend execution guidance; final candidate-02 content manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140. Retained 19 bounded clean-context behavioral samples / 186 raw files: six unguided baselines, seven candidate-01 samples including ready-review control, six candidate-02 samples. Candidate-01's two unnecessary frozen-method recovery conditions and already-correct control remain recorded; narrowed wording and final tests show no extra gate, without causal/statistical improvement claim. Fresh package-focused skill-creator/resource validation passed for workspace, frozen snapshot and scratch copy; patch/whitespace, arithmetic, integrity, unchanged baseline and append-only ownership checks passed. Entry 637 words (2,210 total) and one-model synthetic/procedural-scope limits retained as author concerns. Exact source, patch, hashes, commands, raw evidence and independent-review brief: /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Author task complete; independent source approval pending. No application suites, commits, forge/queue writes, publication, installation, runtime activation or protected actions. 2026-11-04 | #818 / Gitea #1845 canonical skill integration verified | Exact3 reviewed Markdown files match source hashes; canonical skill-creator validator and whitespace checks pass. Independent review APPROVE/no findings,14 focused checks and a fresh bounded forward-use handoff retained. All prior canonical and author log bytes preserved. Package is available in source by explicit path; no managed/personal installation, activation, publication or user-acceptance claim. MVP activation remains pinned to parentbd8120318, independent of this skill commit. + +2026-11-06 | #797 MVP3 project archive round 2 — implementation charter | Owner-directed correction of round 1 candidate 3e21d239b: close archived thread/session execution and product/request/PRD/task admissions, preserve accepted-work completion and history, restore archived navigation and the two CSS hooks. Local feat/mvp3-project-archive-r2 only; no push, merge, deployment, delete, role/contract changes or fleet changes. Cross-lane budget-supervisor schema correction remains for integration. Reviewer-probe regressions first reproduced 8 failures (58 passes); exact runs retained under /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-evidence/. + + +2026-11-06 | #797 MVP3 project archive round 2 — author verification complete | Archive admission now spans queued thread/session execution, product/repository intake, PRD activation, task requirements, bootstrap effects and request-bound manifests/continuations; accepted results, task/review/delivery receipts, cancellation, release/end and history remain usable. Archived navigation exposes saved threads/room history and Unarchive with only the two archive CSS hooks. Final full bus 1618/1618; WebUI 583/583; other path consumers 230/230; final affected lifecycle/launch files 67/67. CLI path gate 585/587: two dependency copy-fixture failures reproduced serially, then whole case2-install file 28/28 after removing an accidental yaml/yaml self-link; all other 31 files passed. Every earlier failed run and correction retained in /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-evidence/. Store/schema remain 3t; cross-lane budget-supervisor correction deferred as directed. Local candidate for independent review only; no push, merge, deployment, delete, role/contract or fleet changes. Report: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index 8785c10f6..10116ed45 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -2065,3 +2065,4 @@ are never rewritten or removed; corrections are new entries. 2026-11-05 | codex-ux-skill-author | Vikunja #818 UX improvement skill author handoff | Candidate-02 three-file package personally authored; package/copy/patch/arithmetic/integrity checks passed; 19 synthetic behavioral samples and all 186 raw artifacts retained with candidate-01 issue/correction and limits. Exact manifest b93d7836ed7c70db62386192d6e1db5c4f867f841aaa7645c3c861def5a9b140; evidence /mnt/storage/scratch/mosaic-lead/skill-ux-improvement/author/run-20261105T022125Z/HANDOFF.md. Independent source review pending; no commit, app suites, forge/queue writes, installation, activation or publication. 2026-11-04 | Mos, skill integrator | #818 / Gitea #1845 | Integrated exact independently approved ms-ux-improvement candidate02 into source;3hashes/validator/whitespace pass and author histories preserved. Separate commit from MVP; its activation target staysbd8120318. No install, activation, publication or live action. 2026-11-06 | mvp3-archive-child (#797 lane) | feat/mvp3-project-archive from 05fdfef9e: owner-only project.archive/unarchive (schema 3s→3t project_archives journal), project.list includeArchived, archived-project refusals on thread/room sends, Console archive UI (new project-archive files), CLI project archive/unarchive/list --archived, restore-guard lists | bus 1601/1601 (Node 24; Node 26 pre-existing socket stall), webui 582/582, plugins 132/132, cli 1191/1194 with two pre-existing load flakes (row253 C4 timing, land_work; both pass isolated, base full-suite fails four of the same family); committed locally, not pushed; report /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r1-report.md +2026-11-06 | mvp3-archive-r2-child (#797 lane) | feat/mvp3-project-archive-r2 from 3e21d239b: close archived thread/session and product/request/PRD/task/bootstrap/manifest admissions; preserve accepted completion, cleanup and reads; repair archived navigation and two-hook styling | bus 1618/1618, WebUI 583/583, other consumers 230/230, lifecycle/launch 67/67; CLI path run 585/587 with both dependency-fixture failures reproduced then superseded by whole-file case2-install 28/28 after self-link cleanup. Schema stays 3t; budget-supervisor integration fix deferred. Exact runs/failures and handoff: /mnt/storage/scratch/mosaic-lead/mvp3/project-archive-r2-report.md. Local commit only; independent review pending; no push, merge, deployment or fleet action. diff --git a/packages/bus/README.md b/packages/bus/README.md index 8417adb7b..92be7b960 100644 --- a/packages/bus/README.md +++ b/packages/bus/README.md @@ -242,7 +242,7 @@ a receipt with empty `changes`. A refusal is `action.refused` with its code alon | `replies` | `{id}`: a message UUID; human and reader sessions only. The messages whose `in_reply_to` is that id, oldest first, at most 50. Writes nothing. | | `project.create` | `{name,repo?}`; human sessions only (row 121, #1615). `repo` must name one of the business's `projects` (`unknown-repo`); a second project with the same name refuses `project-exists`. Returns `{id}`. | | `project.list` | No args, or `{includeArchived:true}`; human, tester and user sessions. The business's projects, oldest first, with archived projects hidden unless `includeArchived` asks for them; each returned archived project carries `archived:true`. A user still lists granted projects only. | -| `project.archive` | `{project}`; human only (#797 MVP3). Appends one `project_archives` row (the audit trail: who, when); nothing is deleted. A repeat of the current state refuses `project-already-archived`. Returns `{project,archived:true}`. While archived the project hides from the plain list and refuses new threads and requests (`project-archived` from `thread.create`, `thread.send`, `room.open`, `room.send`); every existing record stays readable. | +| `project.archive` | `{project}`; human only (#797 MVP3). Appends one `project_archives` row (the audit trail: who, when); nothing is deleted. A repeat of the current state refuses `project-already-archived`. Returns `{project,archived:true}`. While archived the project hides from the plain list and refuses new work (`project-archived` from thread create/open/send, new session clears and thread run admission, room open/send, and product-bound request/PRD/task admission). Repository/product keys resolve to the chat archive journal too. Queued opens and clear successors are rechecked at launch and claim. Existing results, task completion receipts, cancellation, release/end and history stay usable; metadata-only session creation cannot execute while archived. | | `project.unarchive` | `{project}`; human only (#797 MVP3). Restores the project completely; a project that is not archived refuses `project-not-archived`. Returns `{project,archived:false}`. | | `room.open` | `{project}`; human only. Creates/reuses the caller's canonical member threads, named by sorted `THREAD_DEFINITIONS` instance keys. A taken name/owner/instance refuses `room-thread-name-taken` atomically. Returns `{project,threads:[{id,instance,name}]}`. | | `room.send` | `{project,body}`; human only, room must be fully open (`room-not-open`). Leading whitespace and `@name` tokens (including a final EOF tag) address current display names or member keys; no tags broadcasts, repeats send once. Unknown/ambiguous members refuse `unknown-addressee`/`addressee-ambiguous`; tags-only refuses `invalid-request`. Stores the original body, one normal message/route/human.input per target in one transaction, with one shared message timestamp. Returns `{ids,to}` in member-key order. | diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs index 8d4d95a73..5199c3af7 100644 --- a/packages/bus/src/broker.mjs +++ b/packages/bus/src/broker.mjs @@ -438,6 +438,15 @@ function checkBusiness(name, b) { } } +// One archive journal, addressed either by chat UUID or by the repository/product key. +// Multiple chat projects can name a repository: any archived binding fences new work. +export function projectArchived(store, business, project) { + if (!project) return false; + return !!store.get(`SELECT 1 FROM projects p WHERE p.business=? AND (p.id=? OR p.repo=?) + AND (SELECT a.op FROM project_archives a WHERE a.business=p.business AND a.project=p.id ORDER BY a.seq DESC LIMIT 1)='archive' LIMIT 1`, + business, project, project); +} + export class Broker { #store; #requests; @@ -637,6 +646,8 @@ export class Broker { const p = this.#business(business).projects?.[project]; return p && p.kind !== 'library' ? structuredClone(p) : null; }, + projectArchived: (business, project) => this.#archived(business, project), + threadProject: (business, thread) => this.#store.get('SELECT project FROM threads WHERE business=? AND id=?', business, thread)?.project, projectPaths: business => Object.values(this.#business(business).projects ?? {}).flatMap(p => [p.root, p.repo]).filter(p => typeof p === 'string'), raiseDecision: (identity, args) => this.#dispatch(identity, 'decision.raise', args), raisePrdDecision: (identity, args, spec) => this.#dispatch(identity, 'decision.raise', args, spec), @@ -881,6 +892,7 @@ export class Broker { const chat = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', state.chat_project, s.business); if (!chat || chat.repo !== state.project) fail('request-project-refused'); } + if (this.#archived(s.business, state.project) || this.#archived(s.business, state.chat_project)) fail('project-archived'); const board = bindings[0].plan.tracker.project; if (args.project !== undefined && args.project !== board) fail('request-tracker-refused'); return board; @@ -891,6 +903,7 @@ export class Broker { const origin = this.#requestOrigin(s, args.request); const chat = origin.thread && this.#store.get('SELECT project FROM threads WHERE id=? AND business=?', origin.thread, s.business); const associated = chat?.project && this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', chat.project, s.business); + if (this.#archived(s.business, chat?.project) || this.#archived(s.business, binding.project)) fail('project-archived'); if (associated?.repo && associated.repo !== binding.project) fail('request-project-refused'); if (args.project !== undefined && args.project !== binding.tracker_project) fail('request-tracker-refused'); return binding.tracker_project; @@ -907,12 +920,14 @@ export class Broker { const project = args.project_key ?? (args.request !== undefined ? this.#builtinBinding?.project : undefined); if (!projectKey(project) || !this.#business(s.business).projects?.[project] || this.#business(s.business).projects[project].kind === 'library') fail('request-project-refused'); if (args.requirement_snapshot !== undefined && !id(args.requirement_snapshot)) fail('invalid-request'); + if (this.#archived(s.business, project)) fail('project-archived'); const catalog = this.#requests?.catalog ?? this.#catalog; if (!catalog) fail('requirement-snapshot-required'); const snapshot = args.requirement_snapshot ?? (args.project_key === undefined ? this.#builtin?.id : undefined); const result = await catalog.resolve({ business: s.business, project, ...(snapshot === undefined ? {} : { snapshot }) }); this.#agent(s); this.#processingGate(s, 'requirements'); if (args.request !== undefined) this.#createTarget(s, args); + if (this.#archived(s.business, project)) fail('project-archived'); if (args.project_key !== undefined && result.origin !== 'product') fail('requirement-snapshot-required'); if (args.requirement !== undefined && !result.requirements.some(r => r.id === args.requirement)) fail('unknown-requirement'); return result; @@ -1411,11 +1426,7 @@ export class Broker { // #797 MVP3: a project's archive state, the op of its latest project_archives row. Archived // projects keep every record and stay readable; they take no new threads or requests. #archived(business, project) { - return this.#store.get( - 'SELECT op FROM project_archives WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1', - business, - project, - )?.op === 'archive'; + return projectArchived(this.#store, business, project); } // FED R3 (design 3.5): the user's live grants in the session's business, from one read of the // access file. Any failure to read leaves none. Row 275: a grant at any level counts here; each @@ -1620,7 +1631,7 @@ export class Broker { this.#revokeLost('access-expiry', true, doc); rows = this.#threadRows('t.business=?', business); } - return rows.map(({ owner, project, ...r }) => (lost(r, owner, project) ? { ...r, revoked: true } : r)); + return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) })); } // Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides // from a threadsState snapshot, and a message the run took after it would be delivered to a @@ -1879,6 +1890,7 @@ export class Broker { const { key, session, old, body } = this.#launchProof(record); if (!old) this.#processingWork(); if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness'); + if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived'); if (!old && record.thread !== undefined && this.#threadLost(record.business, record.thread)) fail('thread-revoked'); if (!old) this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run)); this.#runs.set(key, session); @@ -6415,6 +6427,7 @@ export class Broker { // Row 121: a thread run claims its thread, so idle threads hold nothing (REQ-THREAD-2). // Row 264a (#1769): the claim is the run's session's, with the session's next generation. if (s.thread) { + if (this.#archived(s.business, this.#threadRow(s, s.thread).project)) fail('project-archived'); if (this.#store.get("SELECT 1 FROM thread_claims WHERE session=? AND holder_run=? AND op='revoke' LIMIT 1", s.session, s.run)) fail('run-revoked'); // FED R4 (design 3.7): no run takes a thread the access file no longer covers. @@ -7581,6 +7594,7 @@ export class Broker { if (!s.tester && !s.user) this.#human(s); keys(a, ['thread', 'session'], ['thread']); const t = this.#grantedThread(s, 'thread.open', this.#thread(s, a.thread)); + if (this.#archived(s.business, t.project)) fail('project-archived'); const session = this.#threadSession(t, a.session); // Row 271 (264d, D7 step 1): a session being cleared, or a retired one, isn't opened. this.#sessionFence(session); @@ -7739,6 +7753,7 @@ export class Broker { identifier(a.op); if (!Number.isSafeInteger(a.revision) || a.revision < 0) fail('invalid-request'); const t = this.#thread(s, a.thread); + if (this.#archived(s.business, t.project)) fail('project-archived'); const session = this.#threadSession(t, a.session); if (a.confirm !== true) fail('confirm-required'); const digest = this.#opDigest(s, 'session.clear', t.id, session, a.revision, a.op); diff --git a/packages/bus/src/request-bootstrap.mjs b/packages/bus/src/request-bootstrap.mjs index 8bf187f8f..9b82205ab 100644 --- a/packages/bus/src/request-bootstrap.mjs +++ b/packages/bus/src/request-bootstrap.mjs @@ -129,6 +129,9 @@ export async function executeBootstrap({ binding, owner, journal, services }) { const workingPath = workingRepo(dataRoot, business, project); const product = dirname(here.root), marker = markerFile({ dataRoot, business, project }); const chatId = chatProjectId(request, planDigest), human = () => writer.read(business).document.human; + const active = () => { + if ([project, plan.source.project, chatId].some(p => services.projectArchived?.(business, p))) refuse('project-archived'); + }; const uncertain = () => { throw new Uncertain('bootstrap-uncertain'); }; const markerOwns = () => { const m = readMarker(marker); @@ -167,6 +170,7 @@ export async function executeBootstrap({ binding, owner, journal, services }) { // what is done and performs nothing further. The effect must not take the organization lock itself; mapping // uses the writer's own exact-add transaction separately. const checkFresh = ({ revision, document }) => { + active(); const stale = () => refuse('request-plan-stale'); if (document.id !== business || document.human !== owner) stale(); const added = writer.findAddition(business, { request, plan_digest: planDigest, project }); @@ -190,6 +194,7 @@ export async function executeBootstrap({ binding, owner, journal, services }) { // One receipt per step. A committed receipt replays its result; anything else re-inspects the evidence. async function step(name, run, { after = true } = {}) { + active(); const op = base(name), last = journal.effects(business, request).filter(r => r.op === op).at(-1); if (last?.stage === 'committed') return last.result; if (last?.stage === 'refused') refuse(last.code ?? 'bootstrap-refused'); @@ -435,8 +440,9 @@ export async function executeBootstrap({ binding, owner, journal, services }) { await step('records', records); const saved = await step('intent', intent); const associated = await step('chat', chat); - const active = await step('activate', activate, { after: false }); - try { services.activate({ business, project, receipt, organization_revision: active.organization_revision }); } + const activated = await step('activate', activate, { after: false }); + active(); + try { services.activate({ business, project, receipt, organization_revision: activated.organization_revision }); } catch (e) { if (e?.code === 'bootstrap-revision-stale') refuse('request-plan-stale'); throw e; } - return { project, chat_project: associated.chat_project, spec_commit: saved.commit, organization_revision: active.organization_revision }; + return { project, chat_project: associated.chat_project, spec_commit: saved.commit, organization_revision: activated.organization_revision }; } diff --git a/packages/bus/src/request-continuation.mjs b/packages/bus/src/request-continuation.mjs index 3b2a0c4a4..540a4ba94 100644 --- a/packages/bus/src/request-continuation.mjs +++ b/packages/bus/src/request-continuation.mjs @@ -3,6 +3,9 @@ import { digest, refuse } from './request-store.mjs'; export function createContinuations({ journal, services }) { + const archived = state => [state.project, state.chat_project, state.thread && services.threadProject?.(state.business, state.thread), + ...(state.selection?.bindings ?? []).flatMap(({ plan }) => [plan.project, plan.chat_project, plan.source?.project])] + .some(project => project && services.projectArchived?.(state.business, project)); const contextFor = (state, target) => { const context = services.continuationContext(state, target); const admission = services.requestAdmission?.(state) ?? null; @@ -21,6 +24,7 @@ export function createContinuations({ journal, services }) { return journal.finishEffect({ ...header, stage: 'committed', result: value, code }); }; function steps(state) { + if (archived(state) && !['delivery', 'complete'].includes(state.phase)) return []; if (state.wait?.kind === 'question' && state.question?.answer === null) return []; if (state.phase === 'selection' && state.wait?.kind !== 'decision') return [[state.pm, 'intake_propose']]; const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest); @@ -111,11 +115,12 @@ export function createContinuations({ journal, services }) { } else attempt(state, row, context); } } - function launches(business) { + function launches(business, includeArchived = false) { const out = []; for (const r of journal.listRequests()) { if (r.business !== business) continue; const state = journal.readRequest(business, r.request); + if (!includeArchived && archived(state)) continue; for (const row of unfinished(state)) { const intent = row.result.intent; if (intent.revision !== state.revision) continue; @@ -128,7 +133,7 @@ export function createContinuations({ journal, services }) { } function launchResult({ business, instance, code, run = null }) { if (typeof business !== 'string' || typeof instance !== 'string' || (code !== null && !/^[a-z-]{1,128}$/.test(code)) || (run !== null && typeof run !== 'string')) refuse('invalid-request'); - for (const i of launches(business).filter(i => i.target === instance || i.chief === instance)) { + for (const i of launches(business, true).filter(i => i.target === instance || i.chief === instance)) { const state = journal.readRequest(business, i.request); const value = { type: 'launch', key: i.key, instance, code, run, prerequisite: i.prerequisite }; receipt(state, `launch:${digest(value)}`, value, code); @@ -136,6 +141,7 @@ export function createContinuations({ journal, services }) { return { recorded: true }; } function manifestNeeded({ business, instance, project, run }) { + if (services.projectArchived?.(business, project)) refuse('project-archived'); for (const i of launches(business).filter(i => i.target === instance && i.project === project && i.run === run)) { const state = journal.readRequest(business, i.request), op = `manifest:${digest([i.key, run, project, i.chief_run, i.prerequisite])}`; if (effects(state).some(e => e.op === op)) continue; diff --git a/packages/bus/src/request-lifecycle.mjs b/packages/bus/src/request-lifecycle.mjs index 5d63c0d9a..34ef2ed20 100644 --- a/packages/bus/src/request-lifecycle.mjs +++ b/packages/bus/src/request-lifecycle.mjs @@ -20,6 +20,10 @@ export function createRequestLifecycle({ journal, services }) { pending.set(k, job); return job.finally(() => { if (pending.get(k) === job) pending.delete(k); }); }; + const active = (state, ...projects) => { + const origin = state.thread && services.threadProject?.(state.business, state.thread); + if ([state.project, state.chat_project, origin, ...projects].some(p => p && services.projectArchived?.(state.business, p))) refuse('project-archived'); + }; const stateOf = (business, request) => journal.readRequest(business, request) ?? refuse('request-not-found'); const actor = identity => ({ role: identity?.role ?? null, run: identity?.run ?? null }); const authorize = (identity, state, operation) => services.authorize(identity, state, operation); @@ -38,6 +42,7 @@ export function createRequestLifecycle({ journal, services }) { if (old) { authorize(identity, old, 'get'); return old; } const state = { business: identity.business, request: args.request, owner: source.owner, pm: source.pm }; authorize(identity, state, 'open'); + active({ ...state, thread: source.thread }); return journal.transaction(tx => { const result = tx.appendRequest({ business: state.business, request: state.request, expectedRevision: 0, kind: 'opened', actor: actor(identity), body: { source_message: source.source_message, thread: source.thread, session: source.session, owner: source.owner, pm: source.pm, @@ -67,12 +72,13 @@ export function createRequestLifecycle({ journal, services }) { const rows = await services.listProjects?.(structuredClone(state), identity, { remember }) ?? []; authorize(identity, stateOf(state.business, state.request), 'get'); if (!Array.isArray(rows)) refuse('invalid-request-projects'); - return rows.filter(p => object(p) && p.business === state.business && key(p.project) && services.project(state.business, p.project) && + return rows.filter(p => object(p) && p.business === state.business && key(p.project) && !services.projectArchived?.(state.business, p.project) && services.project(state.business, p.project) && text(p.name, 200) && object(p.tracker) && Number.isSafeInteger(p.tracker.root) && p.tracker.root > 0 && Number.isSafeInteger(p.tracker.project) && p.tracker.project > 0) .map(p => ({ business: state.business, project: p.project, name: p.name, tracker: { root: p.tracker.root, project: p.tracker.project }, ...(p.mapping_revision ? { mapping_revision: p.mapping_revision } : {}) })); } function localPlan(p, state, args, source) { validatePlan(p); + active(state, p.project, p.source.project, p.chat_project); if (p.business !== state.business || p.request !== state.request || p.project !== args.project || p.name !== args.name || p.source.project !== args.source_project || p.tracker.project !== args.tracker_project || p.staff.pm !== state.pm || p.intent.text !== source.text || p.intent.source_message !== source.source_message || p.intent.generated !== false) refuse('request-plan-binding-refused'); @@ -107,6 +113,8 @@ export function createRequestLifecycle({ journal, services }) { const associated = (rows, p) => Array.isArray(rows) && rows.some(m => object(m) && m.business === p.business && m.project === p.project && m.name === p.name && m.tracker?.root === p.tracker.root && m.tracker?.project === p.tracker.project); function staleCode(plan, state, args, source, rows) { + try { active(state, plan.project, plan.source.project, plan.chat_project); } + catch (e) { if (e.code === 'project-archived') return e.code; throw e; } if (plan.existing && !associated(rows, plan)) return 'request-existing-project-changed'; try { localPlan(plan, state, args, source); } catch (e) { if (!e.code?.startsWith('request-')) throw e; @@ -131,6 +139,7 @@ export function createRequestLifecycle({ journal, services }) { if (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; } if (state.revision !== args.revision) refuse('request-revision-conflict'); if (state.phase !== 'selection' || state.wait?.kind === 'decision') refuse('request-phase-refused'); + active(state, args.source_project, args.project); const source = services.origin(identity, args.request); if (!services.project(state.business, args.source_project)) refuse('request-project-refused'); // The route is journaled when the owner stages it; no setup wait code can erase or imply it. @@ -146,6 +155,7 @@ export function createRequestLifecycle({ journal, services }) { const prepare = existing ? services.prepareExisting : services.preparePlans; const recheck = () => { const current = stateOf(state.business, state.request); authorize(identity, current, 'propose'); + active(current, args.source_project, args.project); if (current.revision !== args.revision) refuse('request-revision-conflict'); return current; }; @@ -206,13 +216,20 @@ export function createRequestLifecycle({ journal, services }) { if (!state.plan_digest) return state; const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest); if (!binding) return state; + const executing = binding.plan.existing + ? services.attachExisting && state.phase === 'discovery' && !state.attachment?.completed + : services.bootstrap && state.phase === 'bootstrap'; + if (!executing) return state; + try { active(state, binding.plan.project, binding.plan.source?.project, binding.plan.chat_project); } + catch (e) { if (e.code !== 'project-archived') throw e; return wait(state, e.code); } if (binding.plan.existing) { - if (!services.attachExisting || state.phase !== 'discovery' || state.attachment?.completed) return state; const execute = async () => { const missing = await admission(binding.plan, state); if (missing) return wait(state, missing); + active(state, binding.plan.project); const outcome = await services.attachExisting(state, binding.plan); return journal.transaction(tx => { const now = stateOf(state.business, state.request); + active(now, binding.plan.project); if (now.revision !== state.revision) refuse('request-revision-conflict'); const header = { version: 1, business: state.business, request: state.request, op: `bootstrap:${binding.digest}:attach`, kind: 'bootstrap', digest: binding.digest }; journal.prepareEffect({ ...header, stage: 'prepared', result: null, code: null }); @@ -224,10 +241,10 @@ export function createRequestLifecycle({ journal, services }) { try { return await (services.withExisting ? services.withExisting(state, execute) : execute()); } catch (e) { return wait(stateOf(state.business, state.request), e.code ?? 'request-attachment-unavailable'); } } - if (!services.bootstrap || state.phase !== 'bootstrap') return state; let done; try { done = await services.bootstrap({ binding: structuredClone(binding), state: structuredClone(state) }); } catch (e) { + if (e?.code === 'project-archived') return wait(state, e.code); if (e?.code === 'request-plan-stale') { // Before any effect the plan is only stale and the owner gets a fresh preview. Once an effect has a committed receipt // (#800 is checked before every effect, not only the first), what is done stays where it is and the request waits, so no @@ -253,10 +270,12 @@ export function createRequestLifecycle({ journal, services }) { } if (state.revision !== args.revision) refuse('request-revision-conflict'); if (!['discovery','prd-review','planning'].includes(state.phase) || state.wait?.code === 'prd-approval') refuse('request-phase-refused'); + active(state); const { revision, op, ...input } = args; const material = await services.readCandidate({ ...input, project: state.project, mode_revision: state.mode_revision }, state.business); const recheck = () => { const now = stateOf(state.business, state.request); authorize(identity, now, 'prd.submit'); + active(now); if (now.revision !== args.revision) refuse('request-revision-conflict'); if (journal.projectState(state.business, state.project)?.revision !== state.mode_revision) refuse('prd-admission-revoked'); return now; @@ -322,6 +341,8 @@ export function createRequestLifecycle({ journal, services }) { const binding = state.selection.bindings.find(b => b.choice === close.choice); if (!binding) refuse('request-selection-refused'); const p = binding.plan; + try { active(state, p.project, p.source.project, p.chat_project); } + catch (e) { return wait(state, e.code); } const planArgs = { project: p.project, name: p.name, source_project: p.source.project, tracker_project: p.tracker.project }; const planSource = { text: p.intent.text, source_message: state.source_message }; const inventory = async () => p.existing ? await services.listProjects?.(structuredClone(state)) ?? [] : null; @@ -403,12 +424,16 @@ export function createRequestLifecycle({ journal, services }) { if (replay(state, args.op, proposal_digest)) return state; if (state.revision !== args.revision) refuse('request-revision-conflict'); if (state.phase !== 'selection' || state.route !== 'existing' || state.wait?.kind !== 'setup' || !PICKER_CODES.includes(state.wait.code)) refuse('request-phase-refused'); + active(state, args.project); // The inventory is asynchronous; the transaction below rereads the journal and the check is repeated there. const picked = await services.pickExisting?.(structuredClone(state), args.project, identity); - if (!(await projects(identity, { request: args.request }, false)).some(p => p.project === args.project)) refuse('request-project-refused'); + const rows = await projects(identity, { request: args.request }, false); + active(state, args.project); + if (!rows.some(p => p.project === args.project)) refuse('request-project-refused'); return journal.transaction(tx => { const current = tx.readRequest(state.business, state.request) ?? refuse('request-not-found'); authorize(identity, current, 'attach'); + active(current, args.project); if (current.revision !== args.revision) refuse('request-revision-conflict'); need('recordAttach')(identity, structuredClone(current), { project: args.project, op: args.op }); return append(current, 'attach.selected', { op: args.op, proposal_digest, project: args.project, ...(picked ? { mapping_revision: picked.mapping_revision } : {}) }, identity, tx.appendRequest); diff --git a/packages/bus/src/requirement-catalog.mjs b/packages/bus/src/requirement-catalog.mjs index b7821ef36..360276560 100644 --- a/packages/bus/src/requirement-catalog.mjs +++ b/packages/bus/src/requirement-catalog.mjs @@ -1,6 +1,6 @@ // Host-only admission. The journal's readCandidate/authority/approval adapters read current host state. import { exact, id, key, text, digest, refuse } from './request-store.mjs'; -export function createRequirementCatalog({ journal, readSpec, builtinBinding }) { +export function createRequirementCatalog({ journal, readSpec, builtinBinding, projectArchived }) { const append = (state, kind, body) => journal.appendRequest({ business: state.business, request: state.request, expectedRevision: state.revision, kind, body, actor: { role: null, run: null } }); const eventFor = snap => journal.snapshotEvents(snap.business, snap.project).filter(e => e.snapshot === snap.id).at(-1); @@ -52,6 +52,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding }) const binding = journal.submission(state.business, state.request, args.decision) ?? refuse('prd-candidate-required'); const old = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision); if (old) return structuredClone(journal.snapshot(old.result.snapshot)); + if (projectArchived?.(state.business, state.project)) refuse('project-archived'); if (state.phase !== 'prd-review' || state.wait?.subject !== args.decision || digest(state.candidate) !== digest(binding.candidate)) refuse('prd-admission-revoked'); const authority = journal.authority(state, binding), approval = journal.approval(state, binding); const material = await journal.readCandidate(binding.candidate, state.business); @@ -61,6 +62,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding }) return journal.transaction(() => { const prior = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision); if (prior) return structuredClone(journal.snapshot(prior.result.snapshot)); + if (projectArchived?.(state.business, state.project)) refuse('project-archived'); const current = journal.readRequest(state.business, state.request); if (current.revision !== state.revision || current.wait?.subject !== args.decision || journal.projectState(state.business, state.project)?.revision !== binding.candidate.mode_revision) refuse('prd-admission-revoked'); if (digest(journal.authority(current, binding)) !== digest(authority) || digest(journal.approval(current, binding)) !== digest(approval)) refuse('prd-admission-revoked'); diff --git a/packages/bus/src/runtime.mjs b/packages/bus/src/runtime.mjs index 81e9c0054..1cb9ad916 100644 --- a/packages/bus/src/runtime.mjs +++ b/packages/bus/src/runtime.mjs @@ -13,7 +13,7 @@ import { hostPreparation } from './request-host.mjs'; import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs'; import * as projectModule from '../../project/src/index.mjs'; import * as libraryModule from '../../library/src/index.mjs'; -import { Broker, BusError, LIBRARY_FETCH_FIX } from './broker.mjs'; +import { Broker, projectArchived, BusError, LIBRARY_FETCH_FIX } from './broker.mjs'; import { Credentials } from './credentials.mjs'; import { serve } from './server.mjs'; import { launchSettings } from '../../business/src/launch-settings.mjs'; @@ -569,6 +569,7 @@ export async function startBroker({ function admission({ business: id, project }) { const no = code => ({ ready: false, revision: null, code }); if (typeof id !== 'string' || typeof project !== 'string' || !Object.hasOwn(businesses, id)) return no('project-unknown'); + if (projectArchived(store, id, project)) return no('project-archived'); let revision = null, declared = false; try { if (writer) { @@ -594,11 +595,11 @@ export async function startBroker({ }; const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval }, readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }), - builtinBinding: services.builtinBinding }); + builtinBinding: services.builtinBinding, projectArchived: services.projectArchived }); return (requestLifecycle = createRequestLifecycle({ journal: requestJournal, services: { catalog, readCandidate, readReviewedArtifact: (state, commit, path) => readReviewedArtifact({ repo: workingRepo(dataRoot, state.business, state.project), commit, path }), readVerification: (state, record) => projectModule.readProjectFile({ business: services.prdBusiness(state.business), project: state.project, dataRoot, path: record.path, commit: record.commit }), requestAdmission: state => admission({ business: state.business, project: state.project }), ...(writer ? hostPreparation({ dataRoot, organization: writer, trackers, journal: requestJournal, catalog, admission, work: fn => processing.work(fn), readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }) }) : {}), ...requestServices, ...services, // The new-project executor needs the organization writer; without one a confirmed selection stays at its setup wait. ...(writer ? { bootstrap: ({ binding, state }) => processing.work(() => executeBootstrap({ binding, owner: state.owner, journal: requestJournal, services: { - dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, + dataRoot, organization: writer, activate: activateManagedProject, barrier: requestServices.barrier, projectArchived: services.projectArchived, chat: { state: args => broker.chatProjectState(args), associate: args => broker.associateChat(args) } } })) } : {}) } })); }, // FED R3: the access file is read per user request; a refusal leaves the user no grant. diff --git a/packages/bus/tests/prd-activation.test.mjs b/packages/bus/tests/prd-activation.test.mjs index 5064e7a07..1d532dd3c 100644 --- a/packages/bus/tests/prd-activation.test.mjs +++ b/packages/bus/tests/prd-activation.test.mjs @@ -238,3 +238,95 @@ test('declined draft returns to discovery and mode change revokes an active snap await f.call('console', 'request.mode.console', { request: f.request, revision: state.revision, mode_revision: state.mode_revision, mode: 'guided', op: 'mode2' }); await assert.rejects(f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id }), { code: 'requirement-snapshot-revoked' }); }); + +const archiveProduct = f => { + const p = f.store.get("SELECT id FROM projects WHERE business='demo' AND repo='timer'"); + assert.ok(p); + f.call('human', 'project.archive', { project: p.id }); + return p.id; +}; +test('archive fences product admission and PRD submission by repository key', async t => { + const f = await setup(t), count = f.store.get('SELECT count(*) n FROM decisions').n; + const p = archiveProduct(f); + assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).code, 'project-archived'); + assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).ready, false); + assert.equal(f.bus.admission({ business: 'demo', project: 'stack' }).ready, true); + await assert.rejects(f.submit(), { code: 'project-archived' }); + assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, count); + assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0); + f.call('human', 'project.unarchive', { project: p }); + assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).ready, true); + assert.ok(await f.submit()); +}); +test('archive after PRD approval refuses activation without publishing a snapshot', async t => { + const f = await setup(t), sub = await f.submit(); + f.approve(sub); archiveProduct(f); + await assert.rejects(f.activate(sub), { code: 'project-archived' }); + assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0); +}); +test('archived products refuse task targets and requirements admission but retain snapshot reads and cancellation', async t => { + const f = await setup(t), sub = await f.submit(); f.approve(sub); + const snap = await f.activate(sub); + archiveProduct(f); + const args = { request: f.request, project_key: 'timer', requirement_snapshot: snap.id, op: 'new-task', requirement: 'REQ-POMO-1' }; + assert.throws(() => f.bus.broker.taskCreateTarget(f.caps.pm, args), { code: 'project-archived' }); + await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, args), { code: 'project-archived' }); + await assert.rejects(f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer', requirement_snapshot: snap.id }), { code: 'project-archived' }); + assert.equal((await f.bus.requirementCatalog.resolve({ business: 'demo', project: 'timer', snapshot: snap.id })).id, snap.id); + const state = await f.call('console', 'request.get.console', { request: f.request }); + const cancelled = await f.call('console', 'request.cancel.console', { request: f.request, revision: state.revision, op: 'cancel-archived' }); + assert.equal(cancelled.phase, 'cancelled'); + assert.deepEqual(await f.bus.requestLifecycle.resume('demo', f.request), cancelled, 'archive recovery must not rewrite terminal request state'); +}); +test('archived products disappear from the request picker and refuse attach/propose explicitly', async t => { + const f = await setup(t); + const sent = f.call('human', 'message.send', { to: 'pm', body: 'New work.' }); + let state = await f.call('pm', 'request.open', { request: sent.request }); + state = await f.call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'another-plan', source_project: 'stack', project: 'new-timer', name: 'New Timer', tracker_project: 3 }); + f.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'existing' }); + state = await f.bus.requestLifecycle.resume('demo', sent.request); + assert.ok((await f.call('console', 'request.projects.console', { request: sent.request })).some(p => p.project === 'timer')); + archiveProduct(f); + assert.ok(!(await f.call('console', 'request.projects.console', { request: sent.request })).some(p => p.project === 'timer')); + await assert.rejects(f.call('console', 'request.attach.console', { request: sent.request, revision: state.revision, project: 'timer', op: 'archived-pick' }), { code: 'project-archived' }); + await assert.rejects(f.call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'archived-plan', source_project: 'timer', project: 'timer', name: 'Timer', tracker_project: 2 }), { code: 'project-archived' }); +}); +test('archive stops new continuations and request-bound launch/manifest recovery', async t => { + const f = await setup(t); + const before = f.store.get('SELECT count(*) n FROM messages').n; + archiveProduct(f); + await f.bus.requestLifecycle.resume('demo', f.request); + assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before); + assert.ok(!f.bus.requestLaunches('demo').some(i => i.project === 'timer')); + assert.throws(() => f.bus.requestManifest({ business: 'demo', instance: 'pm', project: 'timer', run: 'pm-run' }), { code: 'project-archived' }); +}); + +test('a pending resource plan cannot schedule intake from an archived source product', async t => { + const f = await setup(t); + const sent = f.call('human', 'message.send', { to: 'pm', body: 'Build another timer.' }); + let state = await f.call('pm', 'request.open', { request: sent.request }); + state = await f.call('pm', 'request.propose', { request: sent.request, revision: state.revision, op: 'pending-plan', + source_project: 'timer', project: 'another-timer', name: 'Another timer', tracker_project: 3 }); + archiveProduct(f); + f.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'guided' }); + const before = f.store.get('SELECT count(*) n FROM messages').n; + state = await f.bus.requestLifecycle.resume('demo', sent.request); + assert.equal(state.wait.code, 'project-archived'); + assert.equal(f.store.get('SELECT count(*) n FROM messages').n, before); + assert.ok(!f.bus.requestLaunches('demo').some(i => i.request === sent.request)); +}); + +test('activation rechecks archive after asynchronous candidate reads', async t => { + const f = await setup(t), sub = await f.submit(); f.approve(sub); + const activating = f.activate(sub); + archiveProduct(f); + await assert.rejects(activating, { code: 'project-archived' }); + assert.equal(f.store.get('SELECT count(*) n FROM request_snapshots').n, 0); +}); +test('requirements admission rechecks archive after asynchronous catalog reads', async t => { + const f = await setup(t), sub = await f.submit(); f.approve(sub); + const snap = await f.activate(sub); + const admission = f.bus.broker.requirementsFor(f.caps.pm, { project_key: 'timer', requirement_snapshot: snap.id }); + archiveProduct(f); + await assert.rejects(admission, { code: 'project-archived' }); +}); diff --git a/packages/bus/tests/project-archive.test.mjs b/packages/bus/tests/project-archive.test.mjs index e8ec11e80..6170dd177 100644 --- a/packages/bus/tests/project-archive.test.mjs +++ b/packages/bus/tests/project-archive.test.mjs @@ -54,6 +54,10 @@ test('project.archive and project.unarchive are the human\'s alone', (t) => { assert.ok(!list.includes('project.archive') && !list.includes('project.unarchive')); assert.equal(f.code('project.archive', { project: id }, 'tester'), 'tester-refused'); assert.equal(f.code('project.archive', { project: id }, 'agent'), 'human-required'); + assert.throws(() => f.b.bindHuman({ business: 'demo', human: 'not-owner', via: 'cli', outsideAgent: true }), { code: 'human-required' }); + assert.throws(() => f.b.bindConsole({ business: 'demo', human: 'not-owner', subject: { provider: 'test', iss: 'test', sub: 'other' } }), { code: 'human-required' }); + f.caps.console = f.b.bindConsole({ business: 'demo', human: 'jason', subject: { provider: 'test', iss: 'test', sub: 'owner' } }); + assert.equal(f.code('project.archive', { project: id }, 'console'), 'human-required'); const u = setup(t, { grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['continuity'], level: 'collaborator' }] }); assert.equal(u.code('project.archive', { project: id }, 'user'), 'access-refused'); assert.equal(u.code('project.unarchive', { project: id }, 'user'), 'access-refused'); @@ -223,3 +227,55 @@ test('the 3s engine refuses a 3t store, migrating or not, and leaves it as it wa const { root: old } = source3s(t); new Store3s(old).close(); }); + +test('archived threads refuse open, clear and fresh claims, including metadata-only sessions', t => { + const f = setup(t), p = project(f); + const thread = f.call('thread.create', { project: p, name: 'archive-admission', instance: 'continuity' }).id; + const session = f.call('session.list', { thread }).find(s => s.default); + f.call('thread.open', { thread }); // queued before archive + const cap = f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'queued', harness: 'pi', thread, session: session.id }); + f.call('project.archive', { project: p }); + const named = f.call('session.create', { thread, name: 'metadata-only', op: 'metadata-only' }).id; + for (const se of [session.id, named]) { + assert.equal(f.code('thread.open', { thread, session: se }), 'project-archived'); + assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'new-' + se, harness: 'pi', thread, session: se }), { code: 'project-archived' }); + } + assert.equal(f.code('session.clear', { thread, session: session.id, revision: session.revision, confirm: true, op: 'archived-clear' }), 'project-archived'); + assert.throws(() => f.b.request(cap, { verb: 'role.claim' }), { code: 'project-archived' }); + assert.equal(f.store.get('SELECT count(*) n FROM session_ops').n, 0); + assert.equal(f.store.get('SELECT count(*) n FROM thread_claims').n, 0); + f.call('project.unarchive', { project: p }); + assert.deepEqual(f.call('thread.open', { thread, session: named }), { opened: thread }); + assert.ok(f.b.request(cap, { verb: 'role.claim' })); +}); + +test('a queued clear can retire and cancel after archive but cannot start its successor', t => { + const f = setup(t), p = project(f); + const thread = f.call('thread.create', { project: p, name: 'queued-clear', instance: 'continuity' }).id; + const se = f.call('session.list', { thread }).find(s => s.default); + f.call('session.clear', { thread, session: se.id, revision: se.revision, confirm: true, op: 'queued-clear' }); + f.call('project.archive', { project: p }); + assert.equal(f.code('session.clear', { thread, session: se.id, revision: se.revision, confirm: true, op: 'queued-clear' }), 'project-archived'); + const where = { business: 'demo', thread, op: 'queued-clear' }; + f.b.sessionClearExited({ ...where, run: null }); + f.b.sessionClearRetire(where); + const pending = f.b.sessionClears('demo').find(s => s.op === where.op); + assert.ok(pending.successor); + assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'successor', harness: 'pi', thread, session: pending.successor }), { code: 'project-archived' }); + assert.ok(f.call('session.cancel', { thread, op: where.op, confirm: true })); + assert.ok(f.call('session.list', { thread }).length); +}); + +test('archive preserves an accepted run result, release/end and business-wide launch admission', t => { + const f = setup(t), p = project(f); + const thread = f.call('thread.create', { project: p, name: 'accepted', instance: 'continuity' }).id; + const session = f.call('session.list', { thread }).find(s => s.default).id; + f.caps.running = f.b.bindLaunch({ business: 'demo', role: 'continuity', run: 'accepted', harness: 'pi', thread, session }); + f.call('role.claim', {}, 'running'); + f.call('project.archive', { project: p }); + assert.ok(f.call('message.send', { to: 'human', body: 'Accepted work is complete.' }, 'running').id); + assert.equal(f.call('thread.messages', { thread }).at(-1).body, 'Accepted work is complete.'); + assert.ok(f.call('role.release', {}, 'running')); + assert.ok(f.b.endLaunch({ business: 'demo', run: 'accepted', reason: 'done' })); + assert.ok(f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'business-live', harness: 'pi' })); +}); diff --git a/packages/bus/tests/request-bootstrap.test.mjs b/packages/bus/tests/request-bootstrap.test.mjs index 9117569d7..613d09e30 100644 --- a/packages/bus/tests/request-bootstrap.test.mjs +++ b/packages/bus/tests/request-bootstrap.test.mjs @@ -85,6 +85,34 @@ async function setup(t, { barrier, configureFactory = () => {}, beforePropose = rows: () => first.bus.broker.request(caps.human, { verb: 'project.list', args: {} }) }; } +test('bootstrap rechecks archive before reservation after asynchronous preparation', async t => { + const f = await setup(t, { barrier: step => { + if (step === 'begin') { + const source = f.call('human', 'project.create', { name: 'Factory', repo: 'stack' }); + f.call('human', 'project.archive', { project: source.id }); + } + } }); + const state = await f.choose(); + assert.equal(state.wait.code, 'project-archived'); + assert.equal(existsSync(f.paths.product), false); + assert.equal(f.additions().length, 0); +}); + +test('archive during bootstrap blocks activation and preserves earlier effect receipts', async t => { + const f = await setup(t, { barrier: step => { + if (step === 'activate') { + const project = f.rows().find(p => p.repo === 'timer'); + f.call('human', 'project.archive', { project: project.id }); + } + } }); + const state = await f.choose(); + assert.equal(state.wait.code, 'project-archived'); + assert.equal(state.phase, 'bootstrap'); + assert.equal(f.additions().length, 1, 'already committed mapping remains'); + assert.equal(f.bus.admission({ business: 'demo', project: 'timer' }).code, 'project-archived'); + assert.equal(JSON.parse(readFileSync(join(f.paths.product, 'bootstrap.json'), 'utf8')).state, 'reserved'); +}); + test('bootstrap_connects_all_resources', async t => { const f = await setup(t), factoryHead = git(f.source, 'rev-parse', 'HEAD'), orgBefore = f.writer.read('demo'); const done = await f.choose('guided'); diff --git a/packages/bus/tests/request-console.test.mjs b/packages/bus/tests/request-console.test.mjs index b4c133914..a6cb2775b 100644 --- a/packages/bus/tests/request-console.test.mjs +++ b/packages/bus/tests/request-console.test.mjs @@ -17,7 +17,7 @@ import { createRequestLifecycle } from '../src/request-lifecycle.mjs'; const hash = x => createHash('sha256').update(x).digest('hex'); const intent = 'Build a single-file timer.\nKeep these exact bytes.\n'; const SUBJECT = { provider: 'oidc', iss: 'https://id.test', sub: 'jason' }; -function setup(t) { +function setup(t, overrides = {}) { const root = mkdtempSync(join(tmpdir(), 'request-console-')); const store = new Store(root); t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); }); @@ -48,7 +48,7 @@ function setup(t) { }; let lifecycle, journal; const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: services => { - journal = createRequestStore(store); lifecycle = createRequestLifecycle({ journal, services: { ...host, ...services } }); return lifecycle; + journal = createRequestStore(store); lifecycle = createRequestLifecycle({ journal, services: { ...host, ...overrides, ...services } }); return lifecycle; } }); const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }), reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }), @@ -362,3 +362,31 @@ test('a thread message row carries its request id for the owner chat and for no await x.b.request(tester, { verb: 'thread.send', args: { thread: tt, body: 'hello' } }); for (const row of await x.b.request(tester, { verb: 'thread.messages', args: { thread: tt } })) assert.equal('request' in row, false, 'a tester row keeps its old shape'); }); + +for (const operation of ['attach', 'confirm']) test(`archive during async ${operation} inventory refuses before binding a request`, async t => { + let archive = null; + const x = setup(t, { listProjects: async () => { + await Promise.resolve(); + if (archive) { const run = archive; archive = null; await run(); } + return [{ business: 'demo', project: 'old', name: 'Old', tracker: { root: 1, project: 2 } }]; + } }); + const project = (await x.call('human', 'project.create', { name: 'Old', repo: 'old' })).id; + const opened = await x.call('pm', 'request.open', { request: x.source.request }); + let state = await x.call('pm', 'request.propose', x.propose(opened)); + await x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'existing' }); + state = await x.lifecycle.resume('demo', x.source.request); + if (operation === 'confirm') { + state = await x.call('console', 'request.attach.console', { request: x.source.request, revision: state.revision, project: 'old', op: 'pick-old' }); + state = await x.call('pm', 'request.propose', x.existing(state, 'existing-old')); + await x.call('human', 'decision.resolve', { id: state.selection.decision, choice: 'confirm' }); + } + archive = () => x.call('human', 'project.archive', { project }); + if (operation === 'attach') { + await assert.rejects(x.call('console', 'request.attach.console', { request: x.source.request, revision: state.revision, project: 'old', op: 'late-pick' }), { code: 'project-archived' }); + assert.equal(n(x, "SELECT count(*) n FROM request_events WHERE kind='attach.selected'"), 0); + } else { + state = await x.lifecycle.resume('demo', x.source.request); + assert.equal(state.wait.code, 'project-archived'); + assert.equal(n(x, "SELECT count(*) n FROM request_events WHERE kind='selection.confirmed'"), 0); + } +}); diff --git a/packages/bus/tests/request-continuation.test.mjs b/packages/bus/tests/request-continuation.test.mjs index dcef001d8..8e3c64590 100644 --- a/packages/bus/tests/request-continuation.test.mjs +++ b/packages/bus/tests/request-continuation.test.mjs @@ -433,3 +433,30 @@ test('evidence already recorded while PM is absent does not duplicate its queued await f.bus.requestLifecycle.recover(); assert.equal(messages(f, intent.result.intent.key).filter(m => m.to_role === 'pm').length, before); }); + +test('archive preserves accepted task push, review, completion receipts and history', async t => { + const f = await planned(t); + assert.equal((await advance(f, 'accepted')).phase, 'implementation'); + const project = f.store.get("SELECT id FROM projects WHERE repo='timer'").id; + f.call('human', 'project.archive', { project }); + const commit = 'b'.repeat(40); f.push(commit); + const requested = f.review(commit); + assert.equal((await advance(f, 'review-after-archive')).phase, 'review'); + const verdict = f.call('reviewer', 'review.verdict', { task_ref: f.task_ref, request: requested.request, verdict: 'approve', body: 'Accepted work verified' }); + assert.equal((await advance(f, 'delivery-after-archive')).phase, 'delivery'); + f.closeTask(verdict.verdict); + assert.equal(f.store.get("SELECT count(*) n FROM events WHERE subject=? AND kind='task.closed'", f.task_ref).n, 1); + assert.equal((await f.call('human', 'request.get', { request: f.request })).phase, 'delivery'); +}); + +test('archive allows accepted delivery and preserves its completed state during recovery', async t => { + const f = await ready(t); + const project = f.store.get("SELECT id FROM projects WHERE repo='timer'").id; + f.call('human', 'project.archive', { project }); + await f.call('pm', 'request.deliver', f.args); + const completed = f.journal.readRequest('demo', f.request); + assert.equal(completed.phase, 'complete'); + assert.deepEqual(await f.bus.requestLifecycle.resume('demo', f.request), completed); + assert.deepEqual(await f.call('human', 'request.get', { request: f.request }), completed); + assert.ok(completed.delivery); +}); diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs index c602fe1c8..5d4965e7a 100644 --- a/packages/cli/src/launcher.mjs +++ b/packages/cli/src/launcher.mjs @@ -146,7 +146,6 @@ import { dirname, join, relative } from "node:path"; import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs"; import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs"; import { limitHit } from "../../business/src/launch.mjs"; -import { managedState } from "../../business/src/project-bootstrap.mjs"; import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs"; import { createLoader } from "../../plugins/src/loader.mjs"; import { @@ -512,11 +511,21 @@ export function createLauncher({ return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy }; } + // Reread host state on every new-run path, including opens/clears queued before archive. + async function threadAdmission(thread, session) { + if (!thread) return; + const rows = await host.op({ op: "threadsState", business: b.id }); + const row = rows.find(r => r.thread === thread && r.session === session); + if (!row) throw new Refusal("session-not-found"); + if (row.archived) throw new Refusal("project-archived"); + } + // Bundle, spawn, bind, register, hand over the capability. async function start({ instance, launchedBy, resolved, model, account = null, thread = null, session = null, legacy = false, legacyThread = legacy, human = false, admissionGeneration }) { admission(admissionGeneration); // Every launch path ends here, so a --no-launch host spawns nothing whichever path asked. if (!launches) throw new Refusal("launches-disabled", "this host was started with --no-launch"); + await threadAdmission(thread, session); // A thread session's store is checked first, so a bad id or an unsafe // directory launches nothing. let store = null; @@ -569,17 +578,16 @@ export function createLauncher({ // Lifecycle Task 2 (#797): a product the request lifecycle built under /products is cloned only // once the bus has admitted it. The bus answers from its receipts, so a reserved or half-built product // (a mapping published before the seed and records finished) is left out and the launch goes on without - // it. An ordinary mapping is never asked about. + // it. Ordinary mappings also recheck archive state through the same host admission. const codeProjects = {}; for (const [project, entry] of Object.entries(b.projects)) { if (entry.kind === 'library') continue; - if (managedState({ dataRoot, business: b.id, project, entry }) !== 'unmanaged') { - let answer = null; - try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; } - if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; } - } + let answer = null; + try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; } + if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; } codeProjects[project] = entry; } + await threadAdmission(thread, session); let checkouts; try { checkouts = await withProcessing(dataRoot, () => { @@ -697,6 +705,12 @@ export function createLauncher({ readOnly, }; admission(admissionGeneration); + await threadAdmission(thread, session); + // Archive can change while the checkout/bundle is prepared. Never spawn with a stale manifest. + for (const project of Object.keys(codeProjects)) { + const ready = await host.op({ op: "request.admission", business: b.id, project }); + if (ready?.ready !== true) throw new Refusal(ready?.code ?? "project-pending"); + } const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run }); let child; try { diff --git a/packages/cli/tests/request-launch.test.mjs b/packages/cli/tests/request-launch.test.mjs index 3cb8cfc28..e7dc1c0d1 100644 --- a/packages/cli/tests/request-launch.test.mjs +++ b/packages/cli/tests/request-launch.test.mjs @@ -180,3 +180,22 @@ test('an unavailable chief records its exact refusal, stays quiet, and retries t assert.equal((await f.call('pm', 'request.get', { request: f.request })).launch_refusal, null); await launcher.close(); }); + +test('archived product mappings stay out of new manifests while the business still serves live projects', async t => { + const { requestFixture } = await import('../../bus/tests/request-fixture.mjs'); + const x = await requestFixture(t); + withoutHostCredentials(x.f.env, { piKey: true }); + const project = x.store.get("SELECT id FROM projects WHERE repo='timer'"); + assert.ok(project); + await x.call('human', 'project.archive', { project: project.id }); + const fake = fileURLToPath(new URL('../../harness/tests/fixtures/fake-adapter.mjs', import.meta.url)); + const adapter = join(x.f.dataRoot, 'archive-launch.sh'); writeFileSync(adapter, `exec '${process.execPath}' '${fake}'\n`); + const launcher = createLauncher({ host: requestHost({ bus: x.bus, f: x.f, caps: x.caps }), system: x.system, env: x.f.env, + makeLoader: await fakeHarnesses(t, { pi: adapter, 'claude-code': adapter }), harnessVersion: 'fixture', readOnly: [fake], sessionDefaults: { pollInterval: 25 } }); + t.after(() => launcher.close()); await launcher.listen(); + const out = await launcher.fromSession(x.caps.chief, 'coder'); + assert.equal(out.ok, true, out.error); + const session = launcher.registry.sessions.find(s => s.run === out.result.run); + assert.deepEqual(JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'))).projects, ['stack']); + await launcher.close(); +}); diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs index 96016e7ba..77bcddf9c 100644 --- a/packages/cli/tests/thread-launch.test.mjs +++ b/packages/cli/tests/thread-launch.test.mjs @@ -562,3 +562,41 @@ test("C2-05b a real launched bundle gets lookups for a library project without a assert.ok(session); assert.ok(!session.checkouts?.some(checkout => checkout.project === "books")); }); + +test('archive refuses a queued open before spawning and unarchive restores launch', { skip, timeout: 90000 }, async t => { + const prepared = prepare(t), { f } = prepared; + const h = await start(t, prepared, { tickMs: 60000 }); + const project = h.call('project.create', { name: 'archive queue', repo: 'stack' }).id; + const thread = h.call('thread.create', { project, name: 'queued' }).id; + h.call('thread.open', { thread }); + h.call('project.archive', { project }); + await h.launcher.tick(); + assert.equal(launchLog(f).filter(e => e.event === 'launch').length, 0); + assert.ok(launchLog(f).some(e => e.thread === thread && e.code === 'project-archived')); + h.call('project.unarchive', { project }); + await h.launcher.tick(); + assert.ok(launchLog(f).some(e => e.event === 'launch' && e.thread === thread)); +}); + +test('archive during launch preparation is rechecked before runner spawn', { skip, timeout: 90000 }, async t => { + const prepared = prepare(t), { f } = prepared; + let project, archived = false; + const h = await start(t, prepared, { tickMs: 60000, ops: runtime => ({ + credentialStatus: m => { + const result = runtime.credentialStatus(m.business, m.role); + if (project && !archived) { + const cap = runtime.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true }); + runtime.broker.request(cap, { verb: 'project.archive', args: { project } }); + archived = true; + } + return result; + }, + }) }); + project = h.call('project.create', { name: 'archive race', repo: 'stack' }).id; + const thread = h.call('thread.create', { project, name: 'queued' }).id; + h.call('thread.open', { thread }); + await h.launcher.tick(); + assert.equal(archived, true); + assert.equal(launchLog(f).filter(e => e.event === 'launch').length, 0); + assert.ok(launchLog(f).some(e => e.thread === thread && e.code === 'project-archived')); +}); diff --git a/packages/webui/README.md b/packages/webui/README.md index 8329f71fd..e3241a64d 100644 --- a/packages/webui/README.md +++ b/packages/webui/README.md @@ -1196,3 +1196,5 @@ retires the state. Refreshing the page does not restore a pending login. Usage is reserved on this page for P5; P4 adds no usage source. PIN changes require the offline upgrade acceptance documented in packages/model-proxy. + +Archived project routes use the inclusive project read for the name, saved threads and room history. They offer Unarchive in send mode; thread creation, resume, sending and clear controls are unavailable while archived. Archive styling uses only `.project-archive-control` and `.project-archived-filter`. diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js index ce243ab56..15357d0f4 100644 --- a/packages/webui/src/public/chat.js +++ b/packages/webui/src/public/chat.js @@ -75,7 +75,8 @@ const scopeWrites = (project, instance) => !current.user || current.chatGrants.some(g => (g.projects.includes('*') || g.projects.includes(project)) && (instance === undefined || g.instances.includes('*') || g.instances.includes(instance))); - const threadWrites = () => !!current.thread && !current.shared && scopeWrites(current.thread?.project, current.thread?.instance); + const threadAccess = () => !!current.thread && !current.shared && scopeWrites(current.thread?.project, current.thread?.instance); + const threadWrites = () => !current.projectArchived && threadAccess(); function sharing(t) { if (!current.canSend || !threadWrites() || current.tester) return ''; const f = form('share:' + t.id); @@ -99,12 +100,12 @@ value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) : `unknown (${value.reason ?? 'not-collected'})`; function sessionControls(t) { - if (!threadWrites()) return '

Read-only thread.

'; + if (!threadAccess()) return '

Read-only thread.

'; if (current.legacyDefault) return '

Sessions are not available for this account.

'; const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id); const clear = form('clear:' + c.key); const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : ''; - return `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; + return `

Compactions: ${txt(countLabel(selected?.compactions))}

${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · Open fresh successor` : ''}

${txt(f.receipt)}

${reason ? `

${txt(reason)}

` : ''}

${txt(clear.receipt)}

`; } // A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not // its last commit at the cited one (`file`, stored with the check). Either one missing marks nothing. @@ -232,6 +233,7 @@ const roomName = instance => current.members?.find(m => m.instance === instance)?.name || instance; function roomOpenControl(project) { if (!roomOwner()) return ''; + if (current.projects.some(p => p.id === project && p.archived)) return `

Read room

`; const f = form('room-open:' + project); return `
Read room

${txt(f.receipt)}

`; } @@ -243,7 +245,7 @@ return `
  • ${txt(speaker)}${recipients} ${stamp(m.at)}
    ${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}
  • `; }).join(''); const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner(); - return `

    ${link('projects', c.id, p?.name ?? 'Project')}

    ${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}

    Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}

      ${messages || (c.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${c.readFailed ? '' : s.more ? '' : '

    Caught up to the last read.

    '}

    Without leading @tags, your message goes to every member. The broker checks the addressees.

    ${txt(s.receipt)}

    `; + return `

    ${link('projects', c.id, p?.name ?? 'Project')}

    ${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}

    Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}

      ${messages || (c.readFailed ? '
    1. Messages unavailable.
    2. ' : '
    3. No messages yet.
    4. ')}
    ${c.readFailed ? '' : s.more ? '' : '

    Caught up to the last read.

    '}
    ${c.projectArchived ? '

    This project is archived; its room history stays readable.

    ' : `

    Without leading @tags, your message goes to every member. The broker checks the addressees.

    `}

    ${txt(s.receipt)}

    `; } function drawRoomCompletion() { const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body'); @@ -295,7 +297,13 @@ request('projects', undefined, signal), request('threads?project=' + id, undefined, signal), request('instances', undefined, signal), ]); if (g !== generation || out()) return; + if (!projects.rows.some(p => p.id === id) && !user && !tester) { + const resolved = await window.MosaicProjectArchive?.resolve(id, signal); + if (g !== generation || out()) return; + if (resolved) projects.rows.push(resolved); + } if (!projects.rows.some(p => p.id === id)) throw new ChatError('project-not-found', 404); + current.projectArchived = projects.rows.some(p => p.id === id && p.archived === true); if (!catalog.rows.every(m => typeof m?.instance === 'string' && /^[A-Za-z0-9][A-Za-z0-9_.-]{0,159}$/.test(m.instance) && typeof m.name === 'string')) throw new ChatError('invalid-response'); current.projects = projects.rows; current.threads = threads.rows; current.members = catalog.rows.filter(m => threads.rows.some(t => t.project === id && t.instance === m.instance && t.name === m.instance)); @@ -319,7 +327,7 @@ } async function openRoom() { const c = current, id = c?.id; - if (!roomOwner() || !c.canSend || c.channelDisabled || out() || !UUID.test(id ?? '')) return; + if (!roomOwner() || c.projectArchived || !c.canSend || c.channelDisabled || out() || !UUID.test(id ?? '')) return; const f = form('room-open:' + id); if (f.pending) return; f.pending = true; f.receipt = ''; paint(); @@ -340,7 +348,7 @@ } async function sendRoom() { const c = current; - if (!roomOwner() || !c.canSend || !c.roomReady || c.channelDisabled || out()) return; + if (!roomOwner() || c.projectArchived || !c.canSend || !c.roomReady || c.channelDisabled || out()) return; const s = state(c.key), body = s.draft; if (s.pending) return; if (!body.trim() || new TextEncoder().encode(JSON.stringify({ body })).length > 16384) { @@ -390,7 +398,7 @@ } else if (c.kind === 'project') { const p = c.projects.find(p => p.id === c.id); html += heading(p?.name ?? 'Project unavailable'); - if (p && !c.refused) html += roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '

    Shared with me

    ' + threads(c.threads.filter(t => t.shared)) + creation('thread', c.id); + if (p && !c.refused) html += roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '

    Shared with me

    ' + threads(c.threads.filter(t => t.shared)) + (p.archived ? '' : creation('thread', c.id)); } else { html += heading('Chat') + '

    Business projects and your named assistant threads.

    ' + c.projects.map(p => `

    ${link('projects', p.id, p.name)}

    ${p.repo ? `

    Repository: ${txt(p.repo)}

    ` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}
    `).join('') + (c.projects.length ? '' : c.readFailed ? '

    Projects unavailable.

    ' : '

    No projects yet.

    ') + '

    Shared with me

    ' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects'); @@ -463,6 +471,13 @@ current.threads = [...listed.rows, ...shared.rows.filter(t => !own.has(t.id)).map(t => ({ ...t, shared: true }))]; current.thread = current.threads.find(t => t.id === id); current.shared = current.thread?.shared === true; + const projectId = kind === 'project' ? id : current.thread?.project; + if (projectId && !current.projects.some(p => p.id === projectId) && !user && !tester) { + const resolved = await window.MosaicProjectArchive?.resolve(projectId, signal); + if (g !== generation || out()) return; + if (resolved) current.projects = [...current.projects, resolved]; + } + current.projectArchived = current.projects.some(p => p.id === projectId && p.archived === true); const catalog = kind === 'thread' && !current.shared ? await request(`threads/${id}/sessions`, undefined, signal).catch(err => { if (err.code === 'access-refused' && !match[3]) return null; throw err; @@ -715,7 +730,7 @@ } async function cancelClear() { const c = current, key = c.key, f = form('clear:' + key); - if (!c.canSend || !threadWrites() || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return; + if (!c.canSend || !threadAccess() || out() || f.pending || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(f.op ?? '')) return; if (!window.confirm(`Cancel clear operation ${f.op}? Before retirement this restores the old session to active. After retirement it only cancels successor startup. Cancelled messages are never replayed, and nothing is un-retired.`)) return; const op = f.op; f.pending = true; paint(); diff --git a/packages/webui/src/public/project-archive.css b/packages/webui/src/public/project-archive.css index 1916c913e..d09ca0683 100644 --- a/packages/webui/src/public/project-archive.css +++ b/packages/webui/src/public/project-archive.css @@ -1,7 +1,7 @@ /* #797 MVP3: the owner's project archive controls (project-archive.js). Minimal on purpose; the Console's visual lane owns polish. Two hooks come from the shell: the current project's archive panel (.project-archive-control) and the list's Show-archived section - (.project-archived-filter). The shell's .panel, .btn and .muted classes carry the rest. */ + (.project-archived-filter). All archive styling stays beneath these two hooks. */ .project-archive-control ul, .project-archived-filter ul { list-style: none; @@ -17,3 +17,22 @@ gap: 0.5rem; flex-wrap: wrap; } + +.project-archive-control, +.project-archived-filter { + padding: 1rem; + margin-block: 1rem; + border: 1px solid var(--border, #888); + border-radius: 0.4rem; +} +.project-archive-control button, +.project-archived-filter button { + font: inherit; + padding: 0.35rem 0.7rem; + cursor: pointer; +} +.project-archive-control button:disabled, +.project-archived-filter button:disabled { + cursor: default; + opacity: 0.6; +} diff --git a/packages/webui/src/public/project-archive.js b/packages/webui/src/public/project-archive.js index 7af4f833d..859c5cf82 100644 --- a/packages/webui/src/public/project-archive.js +++ b/packages/webui/src/public/project-archive.js @@ -66,7 +66,7 @@ li.append(a, ' '); if (unarchive) { const button = document.createElement('button'); - button.type = 'button'; button.className = 'btn'; button.textContent = 'Unarchive'; + button.type = 'button'; button.textContent = 'Unarchive'; button.addEventListener('click', () => void change(p.id, p.name, 'unarchive')); li.append(button); } @@ -75,7 +75,7 @@ function section(title, kind) { const section = document.createElement('section'); - section.className = 'panel ' + kind; + section.className = kind; section.setAttribute('aria-label', title); const heading = document.createElement('h2'); heading.textContent = title; section.append(heading); @@ -84,22 +84,17 @@ function projectControls(container, current) { const project = current.projects.find(p => p.id === current.id); - const box = section(project ? 'Archive project' : 'Archived project', 'project-archive-control'); + if (!project) return; + const box = section(project.archived ? 'Archived project' : 'Archive project', 'project-archive-control'); const status = document.createElement('p'); status.id = 'project-archive-status'; status.setAttribute('role', 'status'); - if (project) { - const line = document.createElement('p'); - line.textContent = 'Archiving hides this project from the list and refuses new threads and messages. Nothing is deleted.'; - const button = document.createElement('button'); - button.type = 'button'; button.className = 'btn'; button.id = 'project-archive-button'; - button.textContent = 'Archive project'; button.disabled = pending; - button.addEventListener('click', () => void change(project.id, project.name, 'archive')); - box.append(line, button); - } else { - const line = document.createElement('p'); - line.textContent = 'This project is archived. Its records stay readable; unarchive it to use it again.'; - box.append(line); - } + const line = document.createElement('p'); + line.textContent = project.archived ? 'This project is archived. Its saved threads and records stay readable. Unarchive it to start new work.' : 'Archiving hides this project from the list and refuses new work. Nothing is deleted.'; + const button = document.createElement('button'); + button.type = 'button'; button.id = 'project-archive-button'; + button.textContent = project.archived ? 'Unarchive' : 'Archive project'; button.disabled = pending; + button.addEventListener('click', () => void change(project.id, project.name, project.archived ? 'unarchive' : 'archive')); + box.append(line, button); box.append(status); status.replaceChildren(receipt); container.append(box); @@ -108,7 +103,7 @@ function archiveList(container) { const box = section('Archived projects', 'project-archived-filter'); const toggle = document.createElement('button'); - toggle.type = 'button'; toggle.className = 'btn'; toggle.id = 'project-archive-toggle'; + toggle.type = 'button'; toggle.id = 'project-archive-toggle'; toggle.setAttribute('aria-expanded', String(shown)); toggle.textContent = shown ? 'Hide archived' : 'Show archived'; toggle.addEventListener('click', () => { @@ -129,12 +124,12 @@ if (out() || !shown) return; list.replaceChildren(...(rows ?? []).filter(p => p.archived === true).map(p => listRow(p, true))); if (!list.children.length) { - const li = document.createElement('li'); li.className = 'muted'; li.textContent = 'No archived projects.'; + const li = document.createElement('li'); li.textContent = 'No archived projects.'; list.append(li); } }).catch(() => { if (out()) return; - const li = document.createElement('li'); li.className = 'muted'; + const li = document.createElement('li'); li.textContent = 'Archived projects unavailable. Refresh to try again.'; list.replaceChildren(li); }).finally(() => { toggle.disabled = false; }); @@ -147,9 +142,6 @@ function mountProject(container, current) { if (!owner(current)) return; - // The live list carries the project while it is unarchived; an archived project is missing - // from it (chat.js then titles the page "Project unavailable"), which selects the Unarchive - // control. A stale live view answers the button click with the bus's own refusal message. projectControls(container, current); } @@ -162,5 +154,11 @@ function forget() { shown = false; cached = null; pending = false; receipt = ''; } - window.MosaicProjectArchive = Object.freeze({ mount, forget }); + async function resolve(id, signal) { + // Navigation must use a current inclusive read, independent of the list filter/cache. + cached = null; + return (await archived(signal))?.find(p => p.id === id && p.archived === true) ?? null; + } + + window.MosaicProjectArchive = Object.freeze({ mount, forget, resolve }); })(); diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs index f1eabe21f..7b3431fb3 100644 --- a/packages/webui/tests/chat-browser.test.mjs +++ b/packages/webui/tests/chat-browser.test.mjs @@ -593,3 +593,52 @@ test('272 B1: a bad-session deep link preserves the draft and lost clear operati await wait('window.__reads.some(p=>p.includes("/operation?"))'); assert.equal(await b.evaluate('window.__writes.length'),1,'a retained operation is queried, never replaced with a new clear'); }); + +test('archive navigation exposes saved threads and unarchive with only the two archive CSS hooks', async t => { + const initial = `window.__projects=[];window.__archiveRows=[{id:'${P}',name:${JSON.stringify(HOSTILE)},repo:null,archived:true}]; + window.confirm=()=>true; + window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2}]; + window.__operation={op:'pre-archive',thread:'${A}',session:'${A}',successor:'${M}',state:'retired',start:null,refusal:null,at:null}; + const base=window.fetch;window.fetch=async(url,opts)=>String(url)==='/api/chat/projects/archived'?new Response(JSON.stringify({rows:window.__archiveRows})):base(url,opts);`; + const { b, wait, click, set } = await setup(t, '', initial); + await wait('document.querySelector("#project-archive-toggle")'); + assert.equal(await b.evaluate('document.querySelector("#project-archive-list").children.length'), 0); + await click('#project-archive-toggle'); + await wait('document.querySelector("#project-archive-list a")'); + assert.equal(await b.evaluate('document.querySelector("#project-archive-list a").textContent'), HOSTILE); + assert.equal(await b.evaluate('window.injected??null'), null); + const hooks = await b.evaluate('Array.from(document.querySelectorAll(".project-archived-filter, .project-archived-filter [class]")).flatMap(el=>Array.from(el.classList))'); + assert.ok(hooks.every(c => c === 'project-archive-control' || c === 'project-archived-filter'), JSON.stringify(hooks)); + await click('#project-archive-list a'); + await wait('document.querySelector(".project-archive-control")'); + assert.equal(await b.evaluate('document.querySelector(".chat-area h1").textContent'), '\\u001b[31m\\u202eevil'); + assert.equal(await b.evaluate(`document.querySelectorAll('a[href*="${A}"],a[href*="${B}"]').length`), 2); + assert.equal(await b.evaluate('document.querySelector("#chat-thread-form")'), null); + assert.equal(await b.evaluate('document.querySelector("#chat-room-open")'), null); + assert.equal(await b.evaluate('document.querySelector("#project-archive-button").textContent'), 'Unarchive'); + await click(`a[href="#/chat/rooms/${P}"]`); + await wait('document.querySelector(".chat-room")'); + assert.equal(await b.evaluate('document.querySelector("#chat-room-send-form")'), null); + assert.equal(await b.evaluate('document.querySelector("#chat-room-open")'), null); + await click(`.chat-room a[href="#/chat/projects/${P}"]`); + await wait('document.querySelector("#project-archive-button")'); + await click(`a[href="#/chat/threads/${A}"]`); + await wait('document.querySelector(".chat-conversation")'); + assert.equal(await b.evaluate('document.querySelector("#chat-resume")'), null); + assert.equal(await b.evaluate('document.querySelector("#chat-send-form")'), null); + assert.ok(await b.evaluate('!!document.querySelector("#chat-session")')); + assert.equal(await b.evaluate('document.querySelector("#chat-clear").disabled'), true); + await set('#chat-operation', 'pre-archive'); + await click('#chat-operation-form button'); + await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("retired")'); + await b.evaluate(`window.__answers.push({body:{...window.__operation,state:'cancelled'}});true`); + await click('#chat-cancel'); + await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("cancelled")'); + await click(`a[href="#/chat/projects/${P}"]`); + await wait('document.querySelector("#project-archive-button")'); + await b.evaluate(`window.__projects=[{id:'${P}',name:${JSON.stringify(HOSTILE)},repo:null}];window.__archiveRows=[];window.__answers.push({body:{project:'${P}',archived:false}});true`); + await click('#project-archive-button'); + await wait('document.querySelector("#project-archive-button")?.textContent==="Archive project"'); + assert.equal(await b.evaluate('window.__writes.at(-1).path'), `/api/chat/projects/${P}/unarchive`); + assert.ok(await b.evaluate('!!document.querySelector("#chat-thread-form")')); +}); diff --git a/packages/webui/tests/request-browser.test.mjs b/packages/webui/tests/request-browser.test.mjs index 556e55901..a41ab3ef3 100644 --- a/packages/webui/tests/request-browser.test.mjs +++ b/packages/webui/tests/request-browser.test.mjs @@ -78,7 +78,7 @@ const stubFor = boot => `(() => { const data = value => json({ ...value, at: new Date().toISOString() }); if (method === 'POST') return json({ id: 'sent' }); if (path === '/api/chat/instances') return data({ rows: [{ instance: 'assistant', name: 'assistant' }] }); - if (path === '/api/chat/projects') return data({ rows: [] }); + if (path === '/api/chat/projects' || path === '/api/chat/projects/archived') return data({ rows: [] }); if (path.endsWith('/sessions')) { const t = path.split('/')[4]; return data({ rows: [{ id: t, name: 'default', default: true }] }); } if (path.startsWith('/api/chat/threads') && !path.includes('/messages')) return data({ rows: [{ id: '${A}', project: '${A}', name: 'alpha', instance: 'assistant' }] }); if (window.__pages.length) return data(window.__pages.length > 1 && !window.__stay ? window.__pages.shift() : window.__pages[0]);