fix(archive): fence new execution and preserve archived reads (#797)
This commit is contained in:
1 parent
3e21d239b3
commit
1c33c342bc
23 files changed
+508
-62
No files matched your search
@@ -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.
|
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-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.
|
||||||
@@ -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-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-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-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.
|
||||||
@@ -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. |
|
| `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.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.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}`. |
|
| `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.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. |
|
| `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. |
|
||||||
|
|||||||
@@ -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 {
|
export class Broker {
|
||||||
#store;
|
#store;
|
||||||
#requests;
|
#requests;
|
||||||
@@ -637,6 +646,8 @@ export class Broker {
|
|||||||
const p = this.#business(business).projects?.[project];
|
const p = this.#business(business).projects?.[project];
|
||||||
return p && p.kind !== 'library' ? structuredClone(p) : null;
|
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'),
|
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),
|
raiseDecision: (identity, args) => this.#dispatch(identity, 'decision.raise', args),
|
||||||
raisePrdDecision: (identity, args, spec) => this.#dispatch(identity, 'decision.raise', args, spec),
|
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);
|
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 (!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;
|
const board = bindings[0].plan.tracker.project;
|
||||||
if (args.project !== undefined && args.project !== board) fail('request-tracker-refused');
|
if (args.project !== undefined && args.project !== board) fail('request-tracker-refused');
|
||||||
return board;
|
return board;
|
||||||
@@ -891,6 +903,7 @@ export class Broker {
|
|||||||
const origin = this.#requestOrigin(s, args.request);
|
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 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);
|
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 (associated?.repo && associated.repo !== binding.project) fail('request-project-refused');
|
||||||
if (args.project !== undefined && args.project !== binding.tracker_project) fail('request-tracker-refused');
|
if (args.project !== undefined && args.project !== binding.tracker_project) fail('request-tracker-refused');
|
||||||
return binding.tracker_project;
|
return binding.tracker_project;
|
||||||
@@ -907,12 +920,14 @@ export class Broker {
|
|||||||
const project = args.project_key ?? (args.request !== undefined ? this.#builtinBinding?.project : undefined);
|
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 (!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 (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;
|
const catalog = this.#requests?.catalog ?? this.#catalog;
|
||||||
if (!catalog) fail('requirement-snapshot-required');
|
if (!catalog) fail('requirement-snapshot-required');
|
||||||
const snapshot = args.requirement_snapshot ?? (args.project_key === undefined ? this.#builtin?.id : undefined);
|
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 }) });
|
const result = await catalog.resolve({ business: s.business, project, ...(snapshot === undefined ? {} : { snapshot }) });
|
||||||
this.#agent(s); this.#processingGate(s, 'requirements');
|
this.#agent(s); this.#processingGate(s, 'requirements');
|
||||||
if (args.request !== undefined) this.#createTarget(s, args);
|
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.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');
|
if (args.requirement !== undefined && !result.requirements.some(r => r.id === args.requirement)) fail('unknown-requirement');
|
||||||
return result;
|
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
|
// #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.
|
// projects keep every record and stay readable; they take no new threads or requests.
|
||||||
#archived(business, project) {
|
#archived(business, project) {
|
||||||
return this.#store.get(
|
return projectArchived(this.#store, business, project);
|
||||||
'SELECT op FROM project_archives WHERE business=? AND project=? ORDER BY seq DESC LIMIT 1',
|
|
||||||
business,
|
|
||||||
project,
|
|
||||||
)?.op === 'archive';
|
|
||||||
}
|
}
|
||||||
// FED R3 (design 3.5): the user's live grants in the session's business, from one read of the
|
// 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
|
// 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);
|
this.#revokeLost('access-expiry', true, doc);
|
||||||
rows = this.#threadRows('t.business=?', business);
|
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
|
// 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
|
// 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);
|
const { key, session, old, body } = this.#launchProof(record);
|
||||||
if (!old) this.#processingWork();
|
if (!old) this.#processingWork();
|
||||||
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
|
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 && 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));
|
if (!old) this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
||||||
this.#runs.set(key, session);
|
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 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.
|
// Row 264a (#1769): the claim is the run's session's, with the session's next generation.
|
||||||
if (s.thread) {
|
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))
|
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');
|
fail('run-revoked');
|
||||||
// FED R4 (design 3.7): no run takes a thread the access file no longer covers.
|
// 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);
|
if (!s.tester && !s.user) this.#human(s);
|
||||||
keys(a, ['thread', 'session'], ['thread']);
|
keys(a, ['thread', 'session'], ['thread']);
|
||||||
const t = this.#grantedThread(s, 'thread.open', this.#thread(s, a.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);
|
const session = this.#threadSession(t, a.session);
|
||||||
// Row 271 (264d, D7 step 1): a session being cleared, or a retired one, isn't opened.
|
// Row 271 (264d, D7 step 1): a session being cleared, or a retired one, isn't opened.
|
||||||
this.#sessionFence(session);
|
this.#sessionFence(session);
|
||||||
@@ -7739,6 +7753,7 @@ export class Broker {
|
|||||||
identifier(a.op);
|
identifier(a.op);
|
||||||
if (!Number.isSafeInteger(a.revision) || a.revision < 0) fail('invalid-request');
|
if (!Number.isSafeInteger(a.revision) || a.revision < 0) fail('invalid-request');
|
||||||
const t = this.#thread(s, a.thread);
|
const t = this.#thread(s, a.thread);
|
||||||
|
if (this.#archived(s.business, t.project)) fail('project-archived');
|
||||||
const session = this.#threadSession(t, a.session);
|
const session = this.#threadSession(t, a.session);
|
||||||
if (a.confirm !== true) fail('confirm-required');
|
if (a.confirm !== true) fail('confirm-required');
|
||||||
const digest = this.#opDigest(s, 'session.clear', t.id, session, a.revision, a.op);
|
const digest = this.#opDigest(s, 'session.clear', t.id, session, a.revision, a.op);
|
||||||
|
|||||||
@@ -129,6 +129,9 @@ export async function executeBootstrap({ binding, owner, journal, services }) {
|
|||||||
const workingPath = workingRepo(dataRoot, business, project);
|
const workingPath = workingRepo(dataRoot, business, project);
|
||||||
const product = dirname(here.root), marker = markerFile({ 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 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 uncertain = () => { throw new Uncertain('bootstrap-uncertain'); };
|
||||||
const markerOwns = () => {
|
const markerOwns = () => {
|
||||||
const m = readMarker(marker);
|
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
|
// 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.
|
// uses the writer's own exact-add transaction separately.
|
||||||
const checkFresh = ({ revision, document }) => {
|
const checkFresh = ({ revision, document }) => {
|
||||||
|
active();
|
||||||
const stale = () => refuse('request-plan-stale');
|
const stale = () => refuse('request-plan-stale');
|
||||||
if (document.id !== business || document.human !== owner) stale();
|
if (document.id !== business || document.human !== owner) stale();
|
||||||
const added = writer.findAddition(business, { request, plan_digest: planDigest, project });
|
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.
|
// One receipt per step. A committed receipt replays its result; anything else re-inspects the evidence.
|
||||||
async function step(name, run, { after = true } = {}) {
|
async function step(name, run, { after = true } = {}) {
|
||||||
|
active();
|
||||||
const op = base(name), last = journal.effects(business, request).filter(r => r.op === op).at(-1);
|
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 === 'committed') return last.result;
|
||||||
if (last?.stage === 'refused') refuse(last.code ?? 'bootstrap-refused');
|
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);
|
await step('records', records);
|
||||||
const saved = await step('intent', intent);
|
const saved = await step('intent', intent);
|
||||||
const associated = await step('chat', chat);
|
const associated = await step('chat', chat);
|
||||||
const active = await step('activate', activate, { after: false });
|
const activated = await step('activate', activate, { after: false });
|
||||||
try { services.activate({ business, project, receipt, organization_revision: active.organization_revision }); }
|
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; }
|
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 };
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
import { digest, refuse } from './request-store.mjs';
|
import { digest, refuse } from './request-store.mjs';
|
||||||
|
|
||||||
export function createContinuations({ journal, services }) {
|
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 contextFor = (state, target) => {
|
||||||
const context = services.continuationContext(state, target);
|
const context = services.continuationContext(state, target);
|
||||||
const admission = services.requestAdmission?.(state) ?? null;
|
const admission = services.requestAdmission?.(state) ?? null;
|
||||||
@@ -21,6 +24,7 @@ export function createContinuations({ journal, services }) {
|
|||||||
return journal.finishEffect({ ...header, stage: 'committed', result: value, code });
|
return journal.finishEffect({ ...header, stage: 'committed', result: value, code });
|
||||||
};
|
};
|
||||||
function steps(state) {
|
function steps(state) {
|
||||||
|
if (archived(state) && !['delivery', 'complete'].includes(state.phase)) return [];
|
||||||
if (state.wait?.kind === 'question' && state.question?.answer === null) return [];
|
if (state.wait?.kind === 'question' && state.question?.answer === null) return [];
|
||||||
if (state.phase === 'selection' && state.wait?.kind !== 'decision') return [[state.pm, 'intake_propose']];
|
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);
|
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);
|
} else attempt(state, row, context);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function launches(business) {
|
function launches(business, includeArchived = false) {
|
||||||
const out = [];
|
const out = [];
|
||||||
for (const r of journal.listRequests()) {
|
for (const r of journal.listRequests()) {
|
||||||
if (r.business !== business) continue;
|
if (r.business !== business) continue;
|
||||||
const state = journal.readRequest(business, r.request);
|
const state = journal.readRequest(business, r.request);
|
||||||
|
if (!includeArchived && archived(state)) continue;
|
||||||
for (const row of unfinished(state)) {
|
for (const row of unfinished(state)) {
|
||||||
const intent = row.result.intent;
|
const intent = row.result.intent;
|
||||||
if (intent.revision !== state.revision) continue;
|
if (intent.revision !== state.revision) continue;
|
||||||
@@ -128,7 +133,7 @@ export function createContinuations({ journal, services }) {
|
|||||||
}
|
}
|
||||||
function launchResult({ business, instance, code, run = null }) {
|
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');
|
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 state = journal.readRequest(business, i.request);
|
||||||
const value = { type: 'launch', key: i.key, instance, code, run, prerequisite: i.prerequisite };
|
const value = { type: 'launch', key: i.key, instance, code, run, prerequisite: i.prerequisite };
|
||||||
receipt(state, `launch:${digest(value)}`, value, code);
|
receipt(state, `launch:${digest(value)}`, value, code);
|
||||||
@@ -136,6 +141,7 @@ export function createContinuations({ journal, services }) {
|
|||||||
return { recorded: true };
|
return { recorded: true };
|
||||||
}
|
}
|
||||||
function manifestNeeded({ business, instance, project, run }) {
|
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)) {
|
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])}`;
|
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;
|
if (effects(state).some(e => e.op === op)) continue;
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export function createRequestLifecycle({ journal, services }) {
|
|||||||
pending.set(k, job);
|
pending.set(k, job);
|
||||||
return job.finally(() => { if (pending.get(k) === job) pending.delete(k); });
|
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 stateOf = (business, request) => journal.readRequest(business, request) ?? refuse('request-not-found');
|
||||||
const actor = identity => ({ role: identity?.role ?? null, run: identity?.run ?? null });
|
const actor = identity => ({ role: identity?.role ?? null, run: identity?.run ?? null });
|
||||||
const authorize = (identity, state, operation) => services.authorize(identity, state, operation);
|
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; }
|
if (old) { authorize(identity, old, 'get'); return old; }
|
||||||
const state = { business: identity.business, request: args.request, owner: source.owner, pm: source.pm };
|
const state = { business: identity.business, request: args.request, owner: source.owner, pm: source.pm };
|
||||||
authorize(identity, state, 'open');
|
authorize(identity, state, 'open');
|
||||||
|
active({ ...state, thread: source.thread });
|
||||||
return journal.transaction(tx => {
|
return journal.transaction(tx => {
|
||||||
const result = tx.appendRequest({ business: state.business, request: state.request, expectedRevision: 0, kind: 'opened', actor: actor(identity),
|
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,
|
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 }) ?? [];
|
const rows = await services.listProjects?.(structuredClone(state), identity, { remember }) ?? [];
|
||||||
authorize(identity, stateOf(state.business, state.request), 'get');
|
authorize(identity, stateOf(state.business, state.request), 'get');
|
||||||
if (!Array.isArray(rows)) refuse('invalid-request-projects');
|
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)
|
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 } : {}) }));
|
.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) {
|
function localPlan(p, state, args, source) {
|
||||||
validatePlan(p);
|
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 ||
|
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.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');
|
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 &&
|
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);
|
m.name === p.name && m.tracker?.root === p.tracker.root && m.tracker?.project === p.tracker.project);
|
||||||
function staleCode(plan, state, args, source, rows) {
|
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';
|
if (plan.existing && !associated(rows, plan)) return 'request-existing-project-changed';
|
||||||
try { localPlan(plan, state, args, source); } catch (e) {
|
try { localPlan(plan, state, args, source); } catch (e) {
|
||||||
if (!e.code?.startsWith('request-')) throw 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 (old) { if (old.proposal_digest !== proposal_digest) refuse('request-operation-conflict'); return state; }
|
||||||
if (state.revision !== args.revision) refuse('request-revision-conflict');
|
if (state.revision !== args.revision) refuse('request-revision-conflict');
|
||||||
if (state.phase !== 'selection' || state.wait?.kind === 'decision') refuse('request-phase-refused');
|
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);
|
const source = services.origin(identity, args.request);
|
||||||
if (!services.project(state.business, args.source_project)) refuse('request-project-refused');
|
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.
|
// 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 prepare = existing ? services.prepareExisting : services.preparePlans;
|
||||||
const recheck = () => {
|
const recheck = () => {
|
||||||
const current = stateOf(state.business, state.request); authorize(identity, current, 'propose');
|
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');
|
if (current.revision !== args.revision) refuse('request-revision-conflict');
|
||||||
return current;
|
return current;
|
||||||
};
|
};
|
||||||
@@ -206,13 +216,20 @@ export function createRequestLifecycle({ journal, services }) {
|
|||||||
if (!state.plan_digest) return state;
|
if (!state.plan_digest) return state;
|
||||||
const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest);
|
const binding = state.selection?.bindings.find(b => b.digest === state.plan_digest);
|
||||||
if (!binding) return state;
|
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 (binding.plan.existing) {
|
||||||
if (!services.attachExisting || state.phase !== 'discovery' || state.attachment?.completed) return state;
|
|
||||||
const execute = async () => {
|
const execute = async () => {
|
||||||
const missing = await admission(binding.plan, state); if (missing) return wait(state, missing);
|
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);
|
const outcome = await services.attachExisting(state, binding.plan);
|
||||||
return journal.transaction(tx => {
|
return journal.transaction(tx => {
|
||||||
const now = stateOf(state.business, state.request);
|
const now = stateOf(state.business, state.request);
|
||||||
|
active(now, binding.plan.project);
|
||||||
if (now.revision !== state.revision) refuse('request-revision-conflict');
|
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 };
|
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 });
|
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()); }
|
try { return await (services.withExisting ? services.withExisting(state, execute) : execute()); }
|
||||||
catch (e) { return wait(stateOf(state.business, state.request), e.code ?? 'request-attachment-unavailable'); }
|
catch (e) { return wait(stateOf(state.business, state.request), e.code ?? 'request-attachment-unavailable'); }
|
||||||
}
|
}
|
||||||
if (!services.bootstrap || state.phase !== 'bootstrap') return state;
|
|
||||||
let done;
|
let done;
|
||||||
try { done = await services.bootstrap({ binding: structuredClone(binding), state: structuredClone(state) }); }
|
try { done = await services.bootstrap({ binding: structuredClone(binding), state: structuredClone(state) }); }
|
||||||
catch (e) {
|
catch (e) {
|
||||||
|
if (e?.code === 'project-archived') return wait(state, e.code);
|
||||||
if (e?.code === 'request-plan-stale') {
|
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
|
// 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
|
// (#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 (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');
|
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 { revision, op, ...input } = args;
|
||||||
const material = await services.readCandidate({ ...input, project: state.project, mode_revision: state.mode_revision }, state.business);
|
const material = await services.readCandidate({ ...input, project: state.project, mode_revision: state.mode_revision }, state.business);
|
||||||
const recheck = () => {
|
const recheck = () => {
|
||||||
const now = stateOf(state.business, state.request); authorize(identity, now, 'prd.submit');
|
const now = stateOf(state.business, state.request); authorize(identity, now, 'prd.submit');
|
||||||
|
active(now);
|
||||||
if (now.revision !== args.revision) refuse('request-revision-conflict');
|
if (now.revision !== args.revision) refuse('request-revision-conflict');
|
||||||
if (journal.projectState(state.business, state.project)?.revision !== state.mode_revision) refuse('prd-admission-revoked');
|
if (journal.projectState(state.business, state.project)?.revision !== state.mode_revision) refuse('prd-admission-revoked');
|
||||||
return now;
|
return now;
|
||||||
@@ -322,6 +341,8 @@ export function createRequestLifecycle({ journal, services }) {
|
|||||||
const binding = state.selection.bindings.find(b => b.choice === close.choice);
|
const binding = state.selection.bindings.find(b => b.choice === close.choice);
|
||||||
if (!binding) refuse('request-selection-refused');
|
if (!binding) refuse('request-selection-refused');
|
||||||
const p = binding.plan;
|
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 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 planSource = { text: p.intent.text, source_message: state.source_message };
|
||||||
const inventory = async () => p.existing ? await services.listProjects?.(structuredClone(state)) ?? [] : null;
|
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 (replay(state, args.op, proposal_digest)) return state;
|
||||||
if (state.revision !== args.revision) refuse('request-revision-conflict');
|
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');
|
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.
|
// 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);
|
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 => {
|
return journal.transaction(tx => {
|
||||||
const current = tx.readRequest(state.business, state.request) ?? refuse('request-not-found');
|
const current = tx.readRequest(state.business, state.request) ?? refuse('request-not-found');
|
||||||
authorize(identity, current, 'attach');
|
authorize(identity, current, 'attach');
|
||||||
|
active(current, args.project);
|
||||||
if (current.revision !== args.revision) refuse('request-revision-conflict');
|
if (current.revision !== args.revision) refuse('request-revision-conflict');
|
||||||
need('recordAttach')(identity, structuredClone(current), { project: args.project, op: args.op });
|
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);
|
return append(current, 'attach.selected', { op: args.op, proposal_digest, project: args.project, ...(picked ? { mapping_revision: picked.mapping_revision } : {}) }, identity, tx.appendRequest);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Host-only admission. The journal's readCandidate/authority/approval adapters read current host state.
|
// 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';
|
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,
|
const append = (state, kind, body) => journal.appendRequest({ business: state.business, request: state.request,
|
||||||
expectedRevision: state.revision, kind, body, actor: { role: null, run: null } });
|
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);
|
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 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);
|
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 (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');
|
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 authority = journal.authority(state, binding), approval = journal.approval(state, binding);
|
||||||
const material = await journal.readCandidate(binding.candidate, state.business);
|
const material = await journal.readCandidate(binding.candidate, state.business);
|
||||||
@@ -61,6 +62,7 @@ export function createRequirementCatalog({ journal, readSpec, builtinBinding })
|
|||||||
return journal.transaction(() => {
|
return journal.transaction(() => {
|
||||||
const prior = journal.committed(state.business, 'prd-activate').find(e => e.request === state.request && e.result?.decision === args.decision);
|
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 (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);
|
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 (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');
|
if (digest(journal.authority(current, binding)) !== digest(authority) || digest(journal.approval(current, binding)) !== digest(approval)) refuse('prd-admission-revoked');
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { hostPreparation } from './request-host.mjs';
|
|||||||
import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs';
|
import { managedPaths, managedState } from '../../business/src/project-bootstrap.mjs';
|
||||||
import * as projectModule from '../../project/src/index.mjs';
|
import * as projectModule from '../../project/src/index.mjs';
|
||||||
import * as libraryModule from '../../library/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 { Credentials } from './credentials.mjs';
|
||||||
import { serve } from './server.mjs';
|
import { serve } from './server.mjs';
|
||||||
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
import { launchSettings } from '../../business/src/launch-settings.mjs';
|
||||||
@@ -569,6 +569,7 @@ export async function startBroker({
|
|||||||
function admission({ business: id, project }) {
|
function admission({ business: id, project }) {
|
||||||
const no = code => ({ ready: false, revision: null, code });
|
const no = code => ({ ready: false, revision: null, code });
|
||||||
if (typeof id !== 'string' || typeof project !== 'string' || !Object.hasOwn(businesses, id)) return no('project-unknown');
|
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;
|
let revision = null, declared = false;
|
||||||
try {
|
try {
|
||||||
if (writer) {
|
if (writer) {
|
||||||
@@ -594,11 +595,11 @@ export async function startBroker({
|
|||||||
};
|
};
|
||||||
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
|
const catalog = createRequirementCatalog({ journal: { ...requestJournal, readCandidate, authority: services.prdAuthority, approval: services.prdApproval },
|
||||||
readSpec: ({ business, project }) => projectModule.readSpec({ business: services.prdBusiness(business), project, dataRoot }),
|
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,
|
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.
|
// 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: {
|
...(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) } } })) } : {}) } }));
|
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.
|
// FED R3: the access file is read per user request; a refusal leaves the user no grant.
|
||||||
|
|||||||
@@ -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 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' });
|
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' });
|
||||||
|
});
|
||||||
@@ -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.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 }, 'tester'), 'tester-refused');
|
||||||
assert.equal(f.code('project.archive', { project: id }, 'agent'), 'human-required');
|
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' }] });
|
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.archive', { project: id }, 'user'), 'access-refused');
|
||||||
assert.equal(u.code('project.unarchive', { 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);
|
const { root: old } = source3s(t);
|
||||||
new Store3s(old).close();
|
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' }));
|
||||||
|
});
|
||||||
@@ -85,6 +85,34 @@ async function setup(t, { barrier, configureFactory = () => {}, beforePropose =
|
|||||||
rows: () => first.bus.broker.request(caps.human, { verb: 'project.list', args: {} }) };
|
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 => {
|
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 f = await setup(t), factoryHead = git(f.source, 'rev-parse', 'HEAD'), orgBefore = f.writer.read('demo');
|
||||||
const done = await f.choose('guided');
|
const done = await f.choose('guided');
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { createRequestLifecycle } from '../src/request-lifecycle.mjs';
|
|||||||
const hash = x => createHash('sha256').update(x).digest('hex');
|
const hash = x => createHash('sha256').update(x).digest('hex');
|
||||||
const intent = 'Build a single-file timer.\nKeep these exact bytes.\n';
|
const intent = 'Build a single-file timer.\nKeep these exact bytes.\n';
|
||||||
const SUBJECT = { provider: 'oidc', iss: 'https://id.test', sub: 'jason' };
|
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 root = mkdtempSync(join(tmpdir(), 'request-console-'));
|
||||||
const store = new Store(root);
|
const store = new Store(root);
|
||||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||||
@@ -48,7 +48,7 @@ function setup(t) {
|
|||||||
};
|
};
|
||||||
let lifecycle, journal;
|
let lifecycle, journal;
|
||||||
const b = new Broker({ store, businesses: { demo: business }, requestLifecycle: services => {
|
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 }),
|
const caps = { human: b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }),
|
||||||
reader: b.bindReader({ business: 'demo' }), tester: b.bindTester({ business: 'demo' }),
|
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' } });
|
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 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -433,3 +433,30 @@ test('evidence already recorded while PM is absent does not duplicate its queued
|
|||||||
await f.bus.requestLifecycle.recover();
|
await f.bus.requestLifecycle.recover();
|
||||||
assert.equal(messages(f, intent.result.intent.key).filter(m => m.to_role === 'pm').length, before);
|
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);
|
||||||
|
});
|
||||||
@@ -146,7 +146,6 @@ import { dirname, join, relative } from "node:path";
|
|||||||
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs";
|
||||||
import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs";
|
||||||
import { limitHit } from "../../business/src/launch.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 { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs";
|
||||||
import { createLoader } from "../../plugins/src/loader.mjs";
|
import { createLoader } from "../../plugins/src/loader.mjs";
|
||||||
import {
|
import {
|
||||||
@@ -512,11 +511,21 @@ export function createLauncher({
|
|||||||
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy };
|
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.
|
// 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 }) {
|
async function start({ instance, launchedBy, resolved, model, account = null, thread = null, session = null, legacy = false, legacyThread = legacy, human = false, admissionGeneration }) {
|
||||||
admission(admissionGeneration);
|
admission(admissionGeneration);
|
||||||
// Every launch path ends here, so a --no-launch host spawns nothing whichever path asked.
|
// 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");
|
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
|
// A thread session's store is checked first, so a bad id or an unsafe
|
||||||
// directory launches nothing.
|
// directory launches nothing.
|
||||||
let store = null;
|
let store = null;
|
||||||
@@ -569,17 +578,16 @@ export function createLauncher({
|
|||||||
// Lifecycle Task 2 (#797): a product the request lifecycle built under <dataRoot>/products is cloned only
|
// Lifecycle Task 2 (#797): a product the request lifecycle built under <dataRoot>/products is cloned only
|
||||||
// once the bus has admitted it. The bus answers from its receipts, so a reserved or half-built product
|
// 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
|
// (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 = {};
|
const codeProjects = {};
|
||||||
for (const [project, entry] of Object.entries(b.projects)) {
|
for (const [project, entry] of Object.entries(b.projects)) {
|
||||||
if (entry.kind === 'library') continue;
|
if (entry.kind === 'library') continue;
|
||||||
if (managedState({ dataRoot, business: b.id, project, entry }) !== 'unmanaged') {
|
let answer = null;
|
||||||
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' }; }
|
||||||
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; }
|
||||||
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
|
|
||||||
}
|
|
||||||
codeProjects[project] = entry;
|
codeProjects[project] = entry;
|
||||||
}
|
}
|
||||||
|
await threadAdmission(thread, session);
|
||||||
let checkouts;
|
let checkouts;
|
||||||
try {
|
try {
|
||||||
checkouts = await withProcessing(dataRoot, () => {
|
checkouts = await withProcessing(dataRoot, () => {
|
||||||
@@ -697,6 +705,12 @@ export function createLauncher({
|
|||||||
readOnly,
|
readOnly,
|
||||||
};
|
};
|
||||||
admission(admissionGeneration);
|
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 });
|
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||||
let child;
|
let child;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
assert.equal((await f.call('pm', 'request.get', { request: f.request })).launch_refusal, null);
|
||||||
await launcher.close();
|
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();
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
assert.ok(!session.checkouts?.some(checkout => checkout.project === "books"));
|
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'));
|
||||||
|
});
|
||||||
@@ -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
|
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.
|
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`.
|
||||||
@@ -75,7 +75,8 @@
|
|||||||
const scopeWrites = (project, instance) => !current.user || current.chatGrants.some(g =>
|
const scopeWrites = (project, instance) => !current.user || current.chatGrants.some(g =>
|
||||||
(g.projects.includes('*') || g.projects.includes(project)) &&
|
(g.projects.includes('*') || g.projects.includes(project)) &&
|
||||||
(instance === undefined || g.instances.includes('*') || g.instances.includes(instance)));
|
(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) {
|
function sharing(t) {
|
||||||
if (!current.canSend || !threadWrites() || current.tester) return '';
|
if (!current.canSend || !threadWrites() || current.tester) return '';
|
||||||
const f = form('share:' + t.id);
|
const f = form('share:' + t.id);
|
||||||
@@ -99,12 +100,12 @@
|
|||||||
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
|
value.known === true && Number.isSafeInteger(value.count) && value.count >= 0 ? String(value.count) :
|
||||||
`unknown (${value.reason ?? 'not-collected'})`;
|
`unknown (${value.reason ?? 'not-collected'})`;
|
||||||
function sessionControls(t) {
|
function sessionControls(t) {
|
||||||
if (!threadWrites()) return '<p>Read-only thread.</p>';
|
if (!threadAccess()) return '<p>Read-only thread.</p>';
|
||||||
if (current.legacyDefault) return '<p role="status">Sessions are not available for this account.</p>';
|
if (current.legacyDefault) return '<p role="status">Sessions are not available for this account.</p>';
|
||||||
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
|
const c = current, selected = c.sessions.find(r => r.id === c.session), f = form('session:' + t.id);
|
||||||
const clear = form('clear:' + c.key);
|
const clear = form('clear:' + c.key);
|
||||||
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
|
const reason = !Number.isSafeInteger(selected?.revision) ? 'Safe clear is unavailable until its dependency is installed.' : '';
|
||||||
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending)}></label><button class="btn" type="submit"${disabled(f.pending || current.channelDisabled)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
|
return `<section class="chat-session-controls" aria-label="Sessions"><label>Session<select id="chat-session">${c.sessions.map(r => `<option value="${esc(r.id)}"${r.id === c.session ? ' selected' : ''}>${txt(r.name)}${r.default ? ' (default)' : ''} — ${txt(r.state ?? 'status unavailable')}</option>`).join('')}</select></label><p id="chat-compactions">Compactions: ${txt(countLabel(selected?.compactions))}</p><p>${txt(selected?.state ?? 'Session status unavailable')}${UUID.test(selected?.successor) ? ` · <a href="${sessionPath(t.id, selected.successor)}">Open fresh successor</a>` : ''}</p><form id="chat-session-form" class="chat-create" data-key="session:${esc(t.id)}"><label>New session name<input id="chat-session-name" data-chat-field="name" value="${esc(f.name)}" maxlength="200" required${disabled(f.pending || c.projectArchived)}></label><button class="btn" type="submit"${disabled(f.pending || c.projectArchived || current.channelDisabled)}>New session</button><p role="status">${txt(f.receipt)}</p></form><button class="btn" type="button" id="chat-clear"${disabled(c.projectArchived || clear.pending || !!reason || selected?.state && selected.state !== 'active')}>Clear context</button>${reason ? `<p>${txt(reason)}</p>` : ''}<p id="chat-clear-receipt" role="status">${txt(clear.receipt)}</p><form id="chat-operation-form"><label>Check clear operation<input id="chat-operation" value="${esc(clear.op ?? '')}" maxlength="160"${clear.pending ? ' disabled' : ''}></label><button class="btn" type="submit">Check status</button><button class="btn" type="button" id="chat-cancel"${disabled(clear.pending || !!reason || !clear.op || ['cancelled', 'failed'].includes(clear.record?.state) || clear.record?.start === 'started')}>Cancel clear</button></form></section>`;
|
||||||
}
|
}
|
||||||
// A cite is changed when the file's last commit at the served tip (`last`, read with the page) is not
|
// 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.
|
// 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;
|
const roomName = instance => current.members?.find(m => m.instance === instance)?.name || instance;
|
||||||
function roomOpenControl(project) {
|
function roomOpenControl(project) {
|
||||||
if (!roomOwner()) return '';
|
if (!roomOwner()) return '';
|
||||||
|
if (current.projects.some(p => p.id === project && p.archived)) return `<p><a href="#/chat/rooms/${esc(project)}">Read room</a></p>`;
|
||||||
const f = form('room-open:' + project);
|
const f = form('room-open:' + project);
|
||||||
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="#/chat/rooms/${esc(project)}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
|
return `<section class="chat-room-entry" aria-label="Project room"><button class="btn" type="button" id="chat-room-open"${disabled(f.pending || current.channelDisabled)}>Open room</button> <a href="#/chat/rooms/${esc(project)}">Read room</a><p id="chat-room-open-receipt" role="status">${txt(f.receipt)}</p></section>`;
|
||||||
}
|
}
|
||||||
@@ -243,7 +245,7 @@
|
|||||||
return `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(speaker)}</strong>${recipients} ${stamp(m.at)}</div>${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}</li>`;
|
return `<li data-message="${esc(m.id)}"><div class="chat-message-meta"><strong>${txt(speaker)}</strong>${recipients} ${stamp(m.at)}</div>${messageBody({ ...m, from_role: m.from_role === 'human' ? m.from_role : speaker })}</li>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner();
|
const off = !c.roomReady || c.channelDisabled || s.pending || !roomOwner();
|
||||||
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div><form id="chat-room-send-form"><label for="chat-room-body">Message the room</label><p id="chat-room-help">Without leading @tags, your message goes to every member. The broker checks the addressees.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form><p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
|
return `<section class="chat-room" data-project="${esc(c.id)}"><p>${link('projects', c.id, p?.name ?? 'Project')}</p>${heading((p?.name ?? 'Project') + ' room')}${roomOpenControl(c.id)}${libraryLine(libraryOf(c))}<p>Room members: ${(c.members ?? []).map(m => `${txt(m.name)} (@${txt(m.instance)})`).join(', ') || 'unavailable'}</p><ol class="chat-log chat-room-log" tabindex="0" aria-label="Room conversation">${messages || (c.readFailed ? '<li>Messages unavailable.</li>' : '<li>No messages yet.</li>')}</ol><div class="chat-paging">${c.readFailed ? '' : s.more ? '<button class="btn" type="button" id="chat-next">Read next messages</button>' : '<p class="muted">Caught up to the last read.</p>'}<button class="btn" type="button" id="chat-new"${s.follow ? ' hidden' : ''}>New messages — go to end</button></div>${c.projectArchived ? '<p>This project is archived; its room history stays readable.</p>' : `<form id="chat-room-send-form"><label for="chat-room-body">Message the room</label><p id="chat-room-help">Without leading @tags, your message goes to every member. The broker checks the addressees.</p><textarea id="chat-room-body" rows="4" aria-describedby="chat-room-help" aria-autocomplete="list" aria-controls="chat-room-completion" aria-expanded="false"${disabled(off)}>${esc(s.draft)}</textarea><div id="chat-room-tags"></div><ul id="chat-room-completion" class="chat-room-completion" role="listbox" aria-label="Room members" hidden></ul><button class="btn primary" type="submit"${disabled(off)}>Send to room</button></form>`}<p id="chat-room-receipt" role="status">${txt(s.receipt)}</p></section>`;
|
||||||
}
|
}
|
||||||
function drawRoomCompletion() {
|
function drawRoomCompletion() {
|
||||||
const list = document.getElementById('chat-room-completion'), field = document.getElementById('chat-room-body');
|
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),
|
request('projects', undefined, signal), request('threads?project=' + id, undefined, signal), request('instances', undefined, signal),
|
||||||
]);
|
]);
|
||||||
if (g !== generation || out()) return;
|
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);
|
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');
|
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.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));
|
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() {
|
async function openRoom() {
|
||||||
const c = current, id = c?.id;
|
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);
|
const f = form('room-open:' + id);
|
||||||
if (f.pending) return;
|
if (f.pending) return;
|
||||||
f.pending = true; f.receipt = ''; paint();
|
f.pending = true; f.receipt = ''; paint();
|
||||||
@@ -340,7 +348,7 @@
|
|||||||
}
|
}
|
||||||
async function sendRoom() {
|
async function sendRoom() {
|
||||||
const c = current;
|
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;
|
const s = state(c.key), body = s.draft;
|
||||||
if (s.pending) return;
|
if (s.pending) return;
|
||||||
if (!body.trim() || new TextEncoder().encode(JSON.stringify({ body })).length > 16384) {
|
if (!body.trim() || new TextEncoder().encode(JSON.stringify({ body })).length > 16384) {
|
||||||
@@ -390,7 +398,7 @@
|
|||||||
} else if (c.kind === 'project') {
|
} else if (c.kind === 'project') {
|
||||||
const p = c.projects.find(p => p.id === c.id);
|
const p = c.projects.find(p => p.id === c.id);
|
||||||
html += heading(p?.name ?? 'Project unavailable');
|
html += heading(p?.name ?? 'Project unavailable');
|
||||||
if (p && !c.refused) html += roomOpenControl(c.id) + threads(c.threads.filter(t => !t.shared)) + '<h2>Shared with me</h2>' + 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)) + '<h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + (p.archived ? '' : creation('thread', c.id));
|
||||||
} else {
|
} else {
|
||||||
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
|
html += heading('Chat') + '<p>Business projects and your named assistant threads.</p><div class="chat-projects">' +
|
||||||
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + threads(c.threads.filter(t => t.shared)) + creation('project', 'projects');
|
c.projects.map(p => `<section class="panel"><h2>${link('projects', p.id, p.name)}</h2>${p.repo ? `<p class="muted">Repository: ${txt(p.repo)}</p>` : ''}${threads(c.threads.filter(t => t.project === p.id && !t.shared))}</section>`).join('') + (c.projects.length ? '' : c.readFailed ? '<p>Projects unavailable.</p>' : '<p>No projects yet.</p>') + '</div><h2>Shared with me</h2>' + 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.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.thread = current.threads.find(t => t.id === id);
|
||||||
current.shared = current.thread?.shared === true;
|
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 => {
|
const catalog = kind === 'thread' && !current.shared ? await request(`threads/${id}/sessions`, undefined, signal).catch(err => {
|
||||||
if (err.code === 'access-refused' && !match[3]) return null;
|
if (err.code === 'access-refused' && !match[3]) return null;
|
||||||
throw err;
|
throw err;
|
||||||
@@ -715,7 +730,7 @@
|
|||||||
}
|
}
|
||||||
async function cancelClear() {
|
async function cancelClear() {
|
||||||
const c = current, key = c.key, f = form('clear:' + key);
|
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;
|
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;
|
const op = f.op;
|
||||||
f.pending = true; paint();
|
f.pending = true; paint();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* #797 MVP3: the owner's project archive controls (project-archive.js). Minimal on purpose;
|
/* #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
|
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
|
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-archive-control ul,
|
||||||
.project-archived-filter ul {
|
.project-archived-filter ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -17,3 +17,22 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-wrap: wrap;
|
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;
|
||||||
|
}
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
li.append(a, ' ');
|
li.append(a, ' ');
|
||||||
if (unarchive) {
|
if (unarchive) {
|
||||||
const button = document.createElement('button');
|
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'));
|
button.addEventListener('click', () => void change(p.id, p.name, 'unarchive'));
|
||||||
li.append(button);
|
li.append(button);
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
function section(title, kind) {
|
function section(title, kind) {
|
||||||
const section = document.createElement('section');
|
const section = document.createElement('section');
|
||||||
section.className = 'panel ' + kind;
|
section.className = kind;
|
||||||
section.setAttribute('aria-label', title);
|
section.setAttribute('aria-label', title);
|
||||||
const heading = document.createElement('h2'); heading.textContent = title;
|
const heading = document.createElement('h2'); heading.textContent = title;
|
||||||
section.append(heading);
|
section.append(heading);
|
||||||
@@ -84,22 +84,17 @@
|
|||||||
|
|
||||||
function projectControls(container, current) {
|
function projectControls(container, current) {
|
||||||
const project = current.projects.find(p => p.id === current.id);
|
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');
|
const status = document.createElement('p');
|
||||||
status.id = 'project-archive-status'; status.setAttribute('role', 'status');
|
status.id = 'project-archive-status'; status.setAttribute('role', 'status');
|
||||||
if (project) {
|
const line = document.createElement('p');
|
||||||
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.';
|
||||||
line.textContent = 'Archiving hides this project from the list and refuses new threads and messages. Nothing is deleted.';
|
const button = document.createElement('button');
|
||||||
const button = document.createElement('button');
|
button.type = 'button'; button.id = 'project-archive-button';
|
||||||
button.type = 'button'; button.className = 'btn'; button.id = 'project-archive-button';
|
button.textContent = project.archived ? 'Unarchive' : 'Archive project'; button.disabled = pending;
|
||||||
button.textContent = 'Archive project'; button.disabled = pending;
|
button.addEventListener('click', () => void change(project.id, project.name, project.archived ? 'unarchive' : 'archive'));
|
||||||
button.addEventListener('click', () => void change(project.id, project.name, 'archive'));
|
box.append(line, button);
|
||||||
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);
|
|
||||||
}
|
|
||||||
box.append(status);
|
box.append(status);
|
||||||
status.replaceChildren(receipt);
|
status.replaceChildren(receipt);
|
||||||
container.append(box);
|
container.append(box);
|
||||||
@@ -108,7 +103,7 @@
|
|||||||
function archiveList(container) {
|
function archiveList(container) {
|
||||||
const box = section('Archived projects', 'project-archived-filter');
|
const box = section('Archived projects', 'project-archived-filter');
|
||||||
const toggle = document.createElement('button');
|
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.setAttribute('aria-expanded', String(shown));
|
||||||
toggle.textContent = shown ? 'Hide archived' : 'Show archived';
|
toggle.textContent = shown ? 'Hide archived' : 'Show archived';
|
||||||
toggle.addEventListener('click', () => {
|
toggle.addEventListener('click', () => {
|
||||||
@@ -129,12 +124,12 @@
|
|||||||
if (out() || !shown) return;
|
if (out() || !shown) return;
|
||||||
list.replaceChildren(...(rows ?? []).filter(p => p.archived === true).map(p => listRow(p, true)));
|
list.replaceChildren(...(rows ?? []).filter(p => p.archived === true).map(p => listRow(p, true)));
|
||||||
if (!list.children.length) {
|
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);
|
list.append(li);
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (out()) return;
|
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.';
|
li.textContent = 'Archived projects unavailable. Refresh to try again.';
|
||||||
list.replaceChildren(li);
|
list.replaceChildren(li);
|
||||||
}).finally(() => { toggle.disabled = false; });
|
}).finally(() => { toggle.disabled = false; });
|
||||||
@@ -147,9 +142,6 @@
|
|||||||
|
|
||||||
function mountProject(container, current) {
|
function mountProject(container, current) {
|
||||||
if (!owner(current)) return;
|
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);
|
projectControls(container, current);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,5 +154,11 @@
|
|||||||
|
|
||||||
function forget() { shown = false; cached = null; pending = false; receipt = ''; }
|
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 });
|
||||||
})();
|
})();
|
||||||
@@ -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?"))');
|
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');
|
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'), '<img src=x onerror="window.injected=1">\\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")'));
|
||||||
|
});
|
||||||
@@ -78,7 +78,7 @@ const stubFor = boot => `(() => {
|
|||||||
const data = value => json({ ...value, at: new Date().toISOString() });
|
const data = value => json({ ...value, at: new Date().toISOString() });
|
||||||
if (method === 'POST') return json({ id: 'sent' });
|
if (method === 'POST') return json({ id: 'sent' });
|
||||||
if (path === '/api/chat/instances') return data({ rows: [{ instance: 'assistant', name: 'assistant' }] });
|
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.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 (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]);
|
if (window.__pages.length) return data(window.__pages.length > 1 && !window.__stay ? window.__pages.shift() : window.__pages[0]);
|
||||||
|
|||||||
Reference in new issue
Block a user