bus, project: file-backed north-star specs with spec.read and record.write (row 138, #1632)
Row 138 round 2, Rocko's candidate e3f9a400 (43 paths) on26a0b0fab. Dewey approved (comment 28754) after round 1 changes (comment 28752): D1-D5 mutants killed, the 17 touched test files run singly passed 124/124. Rocko's own run: 753 tests and 59/59 mutants killed. The bus gains the project adapter through runtime.mjs, the way it already injects the organization, so broker.mjs takes no new imports and refuses with project-not-configured when no adapter is set. The project socket timeout is 120s. Roles gain record.write. The schema moves from 3e to 3f; the live migration follows MIGRATION.md and needs Mos's window. Sage's landing gate ran onc842686d5plus the candidate: 25 steps, all rc 0 (15 packages, 10 script suites), network sage-r138_default removed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
+45
-3
@@ -590,10 +590,10 @@ scope probes and rotation/expiry notifications; S2 never probes real services.
|
||||
|
||||
## Schema and verification
|
||||
|
||||
`schema.sql` is v3e, SHA-256
|
||||
`21e6c091f14f818396f4d7836f381cc1389d45b2680ecaa1a00646b652c45050`.
|
||||
`schema.sql` is v3f, SHA-256
|
||||
`472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2`.
|
||||
It has 14 tables, 64 triggers and five views. Source digest and a trusted full
|
||||
sqlite_master digest are checked at open, alongside schema version `3e`, WAL,
|
||||
sqlite_master digest are checked at open, alongside schema version `3f`, WAL,
|
||||
quick_check and foreign-key integrity. Missing metadata refuses; it never
|
||||
reinitializes an existing file. Full synchronous WAL transactions either commit
|
||||
or roll back. SQL UPDATE, DELETE and INSERT OR REPLACE refuse on evidence tables.
|
||||
@@ -658,3 +658,45 @@ core; an indexed/insert-scoped optimization requires equivalent checks and tests
|
||||
The schema itself remains byte-identical to v3b. S3 must normalize service read
|
||||
start times to this form before storing snapshots. External service `updated`
|
||||
is not a broker timestamp; its interpretation remains S3's responsibility.
|
||||
|
||||
## Project records and file-backed north-star specs (row 138)
|
||||
|
||||
The business's declared project resolves to its separate local records Git
|
||||
repository (packages/project). `spec.read {project,commit?}` is available to a
|
||||
held role instance, scoped to its business. It returns the committed
|
||||
spec/north-star.md, its last file-changing commit, confirmation flag and up to
|
||||
100 path-history entries with truncated=true if more exist. Optional commit is
|
||||
a full reachable SHA-1; arbitrary revision expressions refuse. Uncommitted bytes
|
||||
are ignored. Only author.name equal to business.human confirms a spec; a latest
|
||||
agent-authored change is visible but unconfirmed, with no fallback to an older
|
||||
human version. A later docs commit does not change the spec's file commit.
|
||||
|
||||
`decision.raise` optionally takes project, spec_path (spec/north-star.md) and
|
||||
spec_commit together. The broker reads the current committed snapshot and
|
||||
refuses a different commit (stale-spec), unconfirmed file (spec-unconfirmed),
|
||||
or incomplete citation (invalid-spec-citation). Missing files and unsafe
|
||||
repositories retain the project reader's refusal codes. Uncited raises keep the
|
||||
existing synchronous validity and routing rules. Decisions store spec_path and
|
||||
spec_commit; later Git commits never reopen or rewrite settled decisions.
|
||||
|
||||
`record.write {project,path,record,decision?}` uses the shared package writer.
|
||||
It checks record.write authority, validates data and binds the author to the
|
||||
session's definition.instance. No caller author/business identity is accepted.
|
||||
Only docs Markdown and requirement/decision JSON are writable. spec/ always
|
||||
refuses project-spec-human-only. The repository's exclusive writer lock refuses
|
||||
concurrent writes with project-busy before they contend for Git's index lock.
|
||||
All eleven v2 role definitions grant record.write within-role. Legacy researcher
|
||||
(v1) and conductor-policy are unchanged. Narrowing limits still apply.
|
||||
|
||||
Git awaits run outside Store transactions. File commits and SQL receipts are
|
||||
two effects: a commit followed by failure to record its authority receipt refuses
|
||||
outcome-unknown and must be inspected, never blindly retried. The unchanged
|
||||
socket request envelope is 64 KiB; package file validation remains 1 MiB. The server
|
||||
awaits only spec.read, record.write and cited decision.raise and holds any
|
||||
transient proof until completion. Other request paths stay synchronous.
|
||||
|
||||
The 3e→3f offline step adds nullable spec_path/spec_commit columns to decisions,
|
||||
with nulls on existing rows. It changes no event or resolution and creates no
|
||||
spec/confirmation tables. The 3e fixture is pinned from 26a0b0fab's tree; no live
|
||||
store is used. Mos runs this step only in an approved stopped-bus/re-pin window,
|
||||
as described in the candidate packet's MIGRATION.md.
|
||||
|
||||
@@ -58,7 +58,7 @@ CREATE TABLE decisions (
|
||||
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||
supersedes TEXT REFERENCES decisions(id)
|
||||
) STRICT;
|
||||
, spec_path TEXT, spec_commit TEXT) STRICT;
|
||||
CREATE TABLE decision_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
|
||||
@@ -16,6 +16,7 @@ export const ACTIONS = Object.freeze([
|
||||
'git.merge.protected',
|
||||
'review.request',
|
||||
'review.verdict',
|
||||
'record.write',
|
||||
'message.send',
|
||||
'message.external',
|
||||
'role.launch',
|
||||
@@ -139,15 +140,17 @@ export class Broker {
|
||||
#credentialStatus;
|
||||
#requirements;
|
||||
#organization;
|
||||
#project;
|
||||
#clock = 0;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null }) {
|
||||
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
this.#businesses = structuredClone(businesses);
|
||||
this.#store = store;
|
||||
this.#secretCheck = secretCheck;
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
this.#project = project;
|
||||
// Row 90 (#1584): the approved PRD's requirement ids. A bad requirements.json refuses here.
|
||||
this.#requirements = loadRequirements();
|
||||
for (const table of [
|
||||
@@ -881,6 +884,9 @@ export class Broker {
|
||||
const args = request.args ?? {};
|
||||
if (!object(args)) fail('invalid-request');
|
||||
this.#secretCheck(args);
|
||||
if (request.verb === 'spec.read' || request.verb === 'record.write' ||
|
||||
(request.verb === 'decision.raise' && ('spec_path' in args || 'spec_commit' in args)))
|
||||
return this.#requestProject(s, { verb: request.verb, args: structuredClone(args) });
|
||||
if (["limits", "limits.set", "organization.set", "plugins", "plugins.set"].includes(request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
@@ -929,6 +935,54 @@ export class Broker {
|
||||
throw this.#refused(s, e, request);
|
||||
}
|
||||
}
|
||||
// Git and the record writer are async effects: never hold a SQL transaction across an await.
|
||||
async #requestProject(s, request) {
|
||||
const { verb, args } = request;
|
||||
let ProjectFailure;
|
||||
try {
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
if (!this.#project) fail('project-not-configured');
|
||||
const { readSpec, writeRecord, ProjectError } = this.#project;
|
||||
ProjectFailure = ProjectError;
|
||||
const business = this.#business(s.business);
|
||||
const context = { business, project: args.project };
|
||||
if (verb === 'spec.read') {
|
||||
keys(args, ['project', 'commit'], ['project']);
|
||||
const result = await readSpec({ ...context, ...(args.commit === undefined ? {} : { commit: args.commit }) });
|
||||
this.#store.transaction(() => this.#agent(s));
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
}
|
||||
if (verb === 'record.write') {
|
||||
keys(args, ['project', 'path', 'record', 'decision'], ['project', 'path', 'record']);
|
||||
const authority = { decision: args.decision ?? null, target: args.project };
|
||||
this.#store.transaction(() => this.#checkAuthority(s, 'record.write', authority));
|
||||
const author = { definition: business.roles[s.role].definition, instance: s.role };
|
||||
// writeRecord takes the repository's exclusive lock before its first await. A concurrent
|
||||
// write to this repository refuses project-busy, including from another bus request.
|
||||
const result = await writeRecord({ ...context, path: args.path, record: args.record, author });
|
||||
try {
|
||||
this.#store.transaction(() => this.#consumeAuthority(s, 'record.write', authority,
|
||||
{ project: args.project, path: result.path, commit: result.commit }));
|
||||
} catch { fail('outcome-unknown'); }
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
}
|
||||
if (typeof args.project !== 'string' || args.spec_path !== 'spec/north-star.md' ||
|
||||
typeof args.spec_commit !== 'string' || !/^[0-9a-f]{40}$/.test(args.spec_commit))
|
||||
fail('invalid-spec-citation');
|
||||
const spec = await readSpec(context);
|
||||
if (!spec.confirmed) fail('spec-unconfirmed');
|
||||
if (args.spec_commit !== spec.commit) fail('stale-spec');
|
||||
return this.#store.transaction(() => {
|
||||
const result = this.#dispatch(s, verb, args, spec);
|
||||
this.#secretCheck(result);
|
||||
return result;
|
||||
});
|
||||
} catch (e) {
|
||||
throw this.#refused(s, ProjectFailure && e instanceof ProjectFailure ? new BusError(e.code) : e, request);
|
||||
}
|
||||
}
|
||||
// The eight task verbs reach the trusted S3 handler here. It runs outside any transaction, gets the
|
||||
// cap to authorize and record through authorize() and recordTask(); refusals are recorded as in request().
|
||||
async requestTask(cap, request, handler) {
|
||||
@@ -1007,7 +1061,7 @@ export class Broker {
|
||||
complete(out.summary);
|
||||
return { ...out.result, untrusted: true, notice: WEB_UNTRUSTED, source: verb };
|
||||
}
|
||||
#dispatch(s, verb, a) {
|
||||
#dispatch(s, verb, a, spec = null) {
|
||||
const b = this.#business(s.business),
|
||||
by = s.human || s.run;
|
||||
switch (verb) {
|
||||
@@ -1101,10 +1155,13 @@ export class Broker {
|
||||
'supersedes',
|
||||
'choice',
|
||||
'approvalChoice',
|
||||
'spec_path',
|
||||
'spec_commit',
|
||||
],
|
||||
['action', 'question', 'options', 'recommendation', 'blocking'],
|
||||
);
|
||||
this.#agent(s);
|
||||
if ((a.spec_path !== undefined || a.spec_commit !== undefined) && !spec) fail('invalid-spec-citation');
|
||||
const cls = this.#classification(s, a.action);
|
||||
string(a.question);
|
||||
if (typeof a.blocking !== 'boolean') fail('invalid-request');
|
||||
@@ -1147,7 +1204,7 @@ export class Broker {
|
||||
}
|
||||
const did = randomUUID();
|
||||
this.#store.run(
|
||||
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,task_ref,requirement_ref,blocking,supersedes,spec_path,spec_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
did,
|
||||
this.#now(),
|
||||
s.business,
|
||||
@@ -1164,6 +1221,8 @@ export class Broker {
|
||||
a.requirement_ref ?? null,
|
||||
Number(a.blocking),
|
||||
a.supersedes ?? null,
|
||||
spec?.path ?? null,
|
||||
spec?.commit ?? null,
|
||||
);
|
||||
this.#event(
|
||||
s,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE decisions ADD COLUMN spec_path TEXT;
|
||||
ALTER TABLE decisions ADD COLUMN spec_commit TEXT;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Store } from './store.mjs';
|
||||
import * as projectModule from '../../project/src/index.mjs';
|
||||
import { Broker, BusError } from './broker.mjs';
|
||||
import { Credentials } from './credentials.mjs';
|
||||
import { serve } from './server.mjs';
|
||||
@@ -66,6 +67,11 @@ export async function startBroker({
|
||||
const broker = new Broker({
|
||||
store,
|
||||
businesses,
|
||||
project: {
|
||||
readSpec: context => projectModule.readSpec({ ...context, dataRoot }),
|
||||
writeRecord: context => projectModule.writeRecord({ ...context, dataRoot }),
|
||||
ProjectError: projectModule.ProjectError,
|
||||
},
|
||||
organization:settings?(id,verb,args,actor)=>verb==='limits'?settings.read(id):verb==='limits.set'?settings.set(id,args,actor):verb==='plugins'?plugins.read(id):plugins.set(id,args,actor):null,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
credentialStatus: () => credentials.status(),
|
||||
|
||||
@@ -96,6 +96,19 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Row138: only project Git effects need an await; preserve every other sync path below.
|
||||
if (r.verb === 'spec.read' || r.verb === 'record.write' ||
|
||||
(r.verb === 'decision.raise' && (Object.hasOwn(r.args ?? {}, 'spec_path') || Object.hasOwn(r.args ?? {}, 'spec_commit')))) {
|
||||
const own = transient;
|
||||
transient = null;
|
||||
// Local Git may take 100 seconds; keep its result beyond the ordinary idle deadline.
|
||||
socket.setTimeout(120_000);
|
||||
Promise.resolve().then(() => broker.request(cap, { verb: r.verb, args: r.args ?? {} })).then(
|
||||
result => { if (!socket.destroyed) finish({ ok: true, result }); },
|
||||
e => { if (!socket.destroyed) finish({ ok: false, error: e instanceof BusError ? e.code : 'invalid-envelope' }); },
|
||||
).finally(() => { if (own) broker.disconnect(own); });
|
||||
return;
|
||||
}
|
||||
const result = broker.request(cap, { verb: r.verb, args: r.args ?? {} });
|
||||
finish({ ok: true, result });
|
||||
} catch (e) {
|
||||
|
||||
@@ -13,8 +13,8 @@ import { join, isAbsolute } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
const ddl = readFileSync(new URL('../schema.sql', import.meta.url), 'utf8');
|
||||
export const SCHEMA_SHA256 = '21e6c091f14f818396f4d7836f381cc1389d45b2680ecaa1a00646b652c45050';
|
||||
export const SCHEMA_VERSION = '3e';
|
||||
export const SCHEMA_SHA256 = '472cbcd0d4548848873f3029e8250dc07bba4fb8db2a93241aae5b8e7e3771e2';
|
||||
export const SCHEMA_VERSION = '3f';
|
||||
// Ordered, named offline steps. New schema changes append a step and keep prior DDL pinned.
|
||||
const legacyDDL = readFileSync(new URL('./migrations/3b.sql', import.meta.url), 'utf8');
|
||||
const LEGACY_SHA256 = '179ffe356d4ff19a49b5ebad39b6c6bfd7771deb8e1b7c55b5e746f039d69e65';
|
||||
@@ -38,6 +38,10 @@ const migrations = [{
|
||||
report: (store) => store.all(
|
||||
"SELECT business, to_role, count(*) AS n FROM messages m WHERE NOT EXISTS(SELECT 1 FROM deliveries d WHERE d.message=m.id AND d.op IN ('delivered','read')) GROUP BY business, to_role ORDER BY business, to_role",
|
||||
).map((r) => `kept ${r.n} undelivered message(s) for ${r.business}/${r.to_role} without a thread; if that role is a thread instance, its thread runs never receive them, so the human re-sends them with thread.send`),
|
||||
}, {
|
||||
from: '3e',
|
||||
to: '3f',
|
||||
sql: readFileSync(new URL('./migrations/3e-3f.sql', import.meta.url), 'utf8'),
|
||||
}];
|
||||
const timedTables = [
|
||||
'events',
|
||||
|
||||
@@ -367,7 +367,7 @@ async function database3c(t) {
|
||||
let source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3c).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3c';")
|
||||
.replace(/\}, \{\n {2}from: '3c',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.notEqual(pinned, source);
|
||||
assert.ok(!pinned.includes('3c-3d'), 'the 3c engine has no 3c→3d step');
|
||||
@@ -391,7 +391,7 @@ test('REQ-ROLE-2, REQ-COS-1: the 3c→3d migration lets the chief of staff end a
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get("SELECT holder_run FROM role_claims WHERE role='coder'").holder_run, 'coder-run');
|
||||
const b = new Broker({ store, businesses: demo(), organization: null });
|
||||
|
||||
@@ -28,12 +28,12 @@ function legacy(t) {
|
||||
return { root, source, path };
|
||||
}
|
||||
|
||||
test('explicit 3b copy migration preserves evidence and opens as 3e; normal startup never migrates', t => {
|
||||
test('explicit 3b copy migration preserves evidence and opens as 3f; normal startup never migrates', t => {
|
||||
const { root, source } = legacy(t);
|
||||
const original = readFileSync(source);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
assert.equal(store.get("SELECT body FROM events WHERE id='preserved'").body, '{"fixture":true}');
|
||||
assert.equal(store.get('SELECT count(*) n FROM task_comment_cursors').n, 0);
|
||||
const epoch = store.get("SELECT value FROM meta WHERE key='comment_cursor_epoch'").value;
|
||||
@@ -62,7 +62,7 @@ test('migration refuses active writers and drifted legacy schemas, with no parti
|
||||
assert.throws(() => new Store(other.root, { migrateSchema: true }), /writer-locked/);
|
||||
});
|
||||
|
||||
test('named migration registry chains 3b through 3e to a fixture-only future version in order', async t => {
|
||||
test('named migration registry chains 3b through 3f to a fixture-only future version in order', async t => {
|
||||
const { root } = legacy(t);
|
||||
const { writeFileSync } = await import('node:fs');
|
||||
const { pathToFileURL } = await import('node:url');
|
||||
@@ -75,15 +75,15 @@ test('named migration registry chains 3b through 3e to a fixture-only future ver
|
||||
const sha = createHash('sha256').update(futureDDL).digest('hex');
|
||||
let source = readFileSync(new URL('../src/store.mjs', import.meta.url), 'utf8');
|
||||
source = source.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${sha}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3f';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3e', to: '3f', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3g';")
|
||||
.replace('}];\nconst timedTables', `}, { from: '3f', to: '3g', sql: ${JSON.stringify(futureSQL)} }];\nconst timedTables`);
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), source);
|
||||
writeFileSync(join(moduleRoot, 'schema.sql'), futureDDL);
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql'])
|
||||
for (const name of ['3b.sql', '3b-3c.sql', '3c-3d.sql', '3d-3e.sql', '3e-3f.sql'])
|
||||
copyFileSync(new URL('../src/migrations/' + name, import.meta.url), join(moduleRoot, 'src/migrations', name));
|
||||
const { Store: FutureStore } = await import(pathToFileURL(join(moduleRoot, 'src/store.mjs')));
|
||||
const future = new FutureStore(root, { migrateSchema: true });
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
assert.equal(future.get("SELECT value FROM meta WHERE key='schema_version'").value, '3g');
|
||||
assert.equal(future.get("SELECT name FROM sqlite_master WHERE name='future_fixture'").name, 'future_fixture');
|
||||
assert.equal(future.get("SELECT count(*) n FROM task_comment_cursors").n, 0);
|
||||
assert.equal(future.get("SELECT id FROM events").id, 'preserved');
|
||||
@@ -109,7 +109,7 @@ test('offline command migrates only the supplied fixture root and reports usage
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.match(migrated.stdout, /bus schema 3e ready/);
|
||||
assert.match(migrated.stdout, /bus schema 3f ready/);
|
||||
new Store(root).close();
|
||||
const usage = spawnSync(process.execPath, [command], { encoding: 'utf8' });
|
||||
assert.equal(usage.status, 2);
|
||||
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
PRAGMA journal_mode = WAL;
|
||||
PRAGMA foreign_keys = ON;
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
|
||||
CREATE TABLE events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN (
|
||||
'session.launched',
|
||||
'session.ended',
|
||||
'action.allowed',
|
||||
'action.refused',
|
||||
'task.created',
|
||||
'task.assigned',
|
||||
'task.state',
|
||||
'task.closed',
|
||||
'task.changed.external',
|
||||
'task.conflict',
|
||||
'task.missing',
|
||||
'review.requested',
|
||||
'review.verdict',
|
||||
'human.input',
|
||||
'config.refused',
|
||||
'credential.expiring',
|
||||
'credential.expired',
|
||||
'credential.changed',
|
||||
'launch.revoked',
|
||||
'launch.restored',
|
||||
'digest.sent')),
|
||||
actor_role TEXT, actor_run TEXT,
|
||||
subject TEXT,
|
||||
corrects TEXT REFERENCES events(id),
|
||||
body TEXT NOT NULL CHECK (json_valid(body))
|
||||
) STRICT;
|
||||
CREATE TABLE role_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, role TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE decisions (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL, project TEXT,
|
||||
raised_by_role TEXT NOT NULL, raised_by_run TEXT NOT NULL,
|
||||
class TEXT NOT NULL CHECK (class IN ('routine','within-role','cross-role','gated')),
|
||||
action TEXT NOT NULL,
|
||||
route_to TEXT NOT NULL,
|
||||
question TEXT NOT NULL,
|
||||
options TEXT NOT NULL CHECK (json_valid(options) AND json_array_length(options) BETWEEN 2 AND 9),
|
||||
recommendation TEXT NOT NULL,
|
||||
task_ref TEXT CHECK (task_ref IS NULL OR (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*')), requirement_ref TEXT,
|
||||
blocking INTEGER NOT NULL CHECK (blocking IN (0,1)),
|
||||
supersedes TEXT REFERENCES decisions(id)
|
||||
) STRICT;
|
||||
CREATE TABLE decision_events (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
decision TEXT NOT NULL REFERENCES decisions(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('seen','resolved','withdrawn','expired')),
|
||||
by TEXT NOT NULL,
|
||||
choice TEXT, note TEXT, via TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE messages (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
from_role TEXT NOT NULL, from_run TEXT NOT NULL,
|
||||
to_role TEXT NOT NULL,
|
||||
class TEXT NOT NULL,
|
||||
in_reply_to TEXT REFERENCES messages(id),
|
||||
decision TEXT REFERENCES decisions(id),
|
||||
corrects TEXT REFERENCES messages(id),
|
||||
body TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE deliveries (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message TEXT NOT NULL REFERENCES messages(id),
|
||||
at TEXT NOT NULL,
|
||||
op TEXT NOT NULL CHECK (op IN ('routed','delivered','failed','read')),
|
||||
holder_run TEXT, transport TEXT, address TEXT, detail TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE task_snapshots (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
task_ref TEXT NOT NULL CHECK (task_ref GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(task_ref, 9) GLOB '*[^0-9/]*' AND NOT substr(task_ref, 9) GLOB '*/*/*'),
|
||||
updated TEXT NOT NULL,
|
||||
etag TEXT,
|
||||
digest TEXT NOT NULL CHECK (length(digest) = 64 AND NOT digest GLOB '*[^0-9a-f]*'),
|
||||
fields TEXT NOT NULL CHECK (json_valid(fields)),
|
||||
source TEXT NOT NULL CHECK (source IN ('self','poll')),
|
||||
via TEXT CHECK (via IN ('board','cursor','task','reconcile')),
|
||||
read_at TEXT,
|
||||
role TEXT, run TEXT,
|
||||
CHECK ((source = 'self') = (role IS NOT NULL AND run IS NOT NULL)),
|
||||
CHECK ((source = 'poll') = (via IS NOT NULL AND read_at IS NOT NULL)),
|
||||
CHECK (json_type(fields, '$.bucket') IS 'integer'
|
||||
OR (source IS 'poll' AND via IS 'task' AND json_type(fields, '$.gone') IS 'text'))
|
||||
) STRICT;
|
||||
CREATE INDEX task_snapshots_ref ON task_snapshots (business, task_ref, seq);
|
||||
CREATE TRIGGER decisions_resolve_once BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op IN ('resolved','withdrawn','expired') AND EXISTS (
|
||||
SELECT 1 FROM decision_events WHERE decision = NEW.decision AND op IN ('resolved','withdrawn','expired'))
|
||||
BEGIN SELECT RAISE(ABORT, 'decision already closed'); END;
|
||||
CREATE TRIGGER decisions_resolved_choice BEFORE INSERT ON decision_events
|
||||
WHEN NEW.op = 'resolved' AND (NEW.choice IS NULL OR NOT EXISTS (
|
||||
SELECT 1 FROM decisions d, json_each(d.options) o WHERE d.id = NEW.decision AND json_extract(o.value,'$.key') = NEW.choice))
|
||||
BEGIN SELECT RAISE(ABORT, 'resolution must name one of the options'); END;
|
||||
CREATE TRIGGER role_one_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role already held'); END;
|
||||
CREATE TRIGGER role_release_by_holder BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'role is not held'); END;
|
||||
CREATE TRIGGER role_release_same_run BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM role_claims WHERE business = NEW.business AND role = NEW.role ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER role_revoke_needs_decision BEFORE INSERT ON role_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER meta_no_update BEFORE UPDATE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER meta_no_delete BEFORE DELETE ON meta BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_update BEFORE UPDATE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_delete BEFORE DELETE ON role_claims BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_update BEFORE UPDATE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_delete BEFORE DELETE ON decisions BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_update BEFORE UPDATE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_delete BEFORE DELETE ON decision_events BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_update BEFORE UPDATE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER messages_no_delete BEFORE DELETE ON messages BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_update BEFORE UPDATE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_delete BEFORE DELETE ON deliveries BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER meta_no_replace BEFORE INSERT ON meta WHEN EXISTS (SELECT 1 FROM meta WHERE key = NEW.key) BEGIN SELECT RAISE(ABORT, 'meta is append-only'); END;
|
||||
CREATE TRIGGER events_no_replace BEFORE INSERT ON events WHEN EXISTS (SELECT 1 FROM events WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'events is append-only'); END;
|
||||
CREATE TRIGGER role_claims_no_replace BEFORE INSERT ON role_claims WHEN EXISTS (SELECT 1 FROM role_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'role_claims is append-only'); END;
|
||||
CREATE TRIGGER decisions_no_replace BEFORE INSERT ON decisions WHEN EXISTS (SELECT 1 FROM decisions WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
|
||||
CREATE TRIGGER decision_events_no_replace BEFORE INSERT ON decision_events WHEN EXISTS (SELECT 1 FROM decision_events WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'decision_events is append-only'); END;
|
||||
CREATE TRIGGER messages_no_replace BEFORE INSERT ON messages WHEN EXISTS (SELECT 1 FROM messages WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'messages is append-only'); END;
|
||||
CREATE TRIGGER deliveries_no_replace BEFORE INSERT ON deliveries WHEN EXISTS (SELECT 1 FROM deliveries WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'deliveries is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_update BEFORE UPDATE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_delete BEFORE DELETE ON task_snapshots BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER task_snapshots_no_replace BEFORE INSERT ON task_snapshots WHEN EXISTS (SELECT 1 FROM task_snapshots WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'task_snapshots is append-only'); END;
|
||||
CREATE TRIGGER decisions_blocking_needs_task BEFORE INSERT ON decisions
|
||||
WHEN NEW.blocking = 1 AND NEW.task_ref IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'a blocking decision cites the task it blocks'); END;
|
||||
CREATE TRIGGER events_launch_by_human BEFORE INSERT ON events
|
||||
WHEN NEW.kind IN ('launch.revoked','launch.restored') AND (NEW.actor_role IS NOT NULL OR NEW.actor_run IS NOT NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'only the human revokes or restores launching'); END;
|
||||
CREATE TRIGGER events_credential_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'credential.*' AND (
|
||||
json_extract(NEW.body, '$.service') IS NULL OR json_extract(NEW.body, '$.service') NOT IN ('gitea','vikunja')
|
||||
OR json_extract(NEW.body, '$.instance') IS NULL)
|
||||
BEGIN SELECT RAISE(ABORT, 'credential events name a service and a role instance'); END;
|
||||
CREATE TRIGGER events_task_missing_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.missing' AND (
|
||||
json_extract(NEW.body, '$.reason') IS NULL OR json_extract(NEW.body, '$.reason') NOT IN ('moved','not-found','no-access')
|
||||
OR (json_extract(NEW.body, '$.reason') = 'moved' AND json_type(NEW.body, '$.project') IS NOT 'integer'))
|
||||
BEGIN SELECT RAISE(ABORT, 'task.missing carries a reason, and the new project when moved'); END;
|
||||
CREATE TRIGGER events_task_subject BEFORE INSERT ON events
|
||||
WHEN NEW.kind GLOB 'task.*' AND (NEW.subject IS NULL OR NOT (NEW.subject GLOB 'vikunja:[1-9]*/[1-9]*' AND NOT substr(NEW.subject, 9) GLOB '*[^0-9/]*' AND NOT substr(NEW.subject, 9) GLOB '*/*/*'))
|
||||
BEGIN SELECT RAISE(ABORT, 'a task event names its task in subject'); END;
|
||||
CREATE TRIGGER events_task_created_body BEFORE INSERT ON events
|
||||
WHEN NEW.kind = 'task.created' AND (
|
||||
json_type(NEW.body, '$.request') IS NOT 'text'
|
||||
OR NOT EXISTS (SELECT 1 FROM events WHERE id = json_extract(NEW.body, '$.request')
|
||||
AND kind = 'human.input' AND business = NEW.business)
|
||||
OR json_type(NEW.body, '$.requirement') IS NOT 'text'
|
||||
OR NOT json_extract(NEW.body, '$.requirement') GLOB 'REQ-[A-Z]*-[1-9]*'
|
||||
OR json_extract(NEW.body, '$.requirement') GLOB 'REQ-*[^A-Z0-9-]*')
|
||||
BEGIN SELECT RAISE(ABORT, 'task.created cites the human.input that asked for it and a requirement id'); END;
|
||||
CREATE VIEW launch_state AS
|
||||
SELECT business, CASE kind WHEN 'launch.revoked' THEN 'revoked' ELSE 'allowed' END AS state, at
|
||||
FROM events e WHERE kind IN ('launch.revoked','launch.restored')
|
||||
AND seq = (SELECT max(seq) FROM events WHERE business = e.business AND kind IN ('launch.revoked','launch.restored'));
|
||||
CREATE VIEW task_external_changes AS
|
||||
SELECT p.business, p.task_ref, p.seq, p.via, p.updated, p.digest, ls.digest AS self_digest
|
||||
FROM task_snapshots p
|
||||
LEFT JOIN task_snapshots ls ON ls.seq = (SELECT max(seq) FROM task_snapshots
|
||||
WHERE business = p.business AND task_ref = p.task_ref AND source = 'self')
|
||||
WHERE p.source = 'poll'
|
||||
AND p.seq = (SELECT max(seq) FROM task_snapshots WHERE business = p.business AND task_ref = p.task_ref)
|
||||
AND (ls.seq IS NULL OR (p.updated >= ls.updated AND p.read_at > ls.at AND p.digest <> ls.digest));
|
||||
CREATE VIEW task_current AS
|
||||
SELECT s.seq, s.at, s.business, s.task_ref, s.updated, s.etag, s.digest, s.fields, s.source, s.via, s.read_at, s.role, s.run
|
||||
FROM task_snapshots s
|
||||
WHERE s.seq = (SELECT max(c.seq) FROM task_snapshots c
|
||||
WHERE c.business = s.business AND c.task_ref = s.task_ref
|
||||
AND (c.source = 'self'
|
||||
OR c.read_at > coalesce((SELECT ls.at FROM task_snapshots ls
|
||||
WHERE ls.business = c.business AND ls.task_ref = c.task_ref AND ls.source = 'self'
|
||||
ORDER BY ls.seq DESC LIMIT 1), '')));
|
||||
CREATE VIEW tasks_open AS
|
||||
SELECT s.business, s.task_ref, json_extract(s.fields, '$.bucket') AS bucket, s.seq
|
||||
FROM task_current s
|
||||
WHERE json_type(s.fields, '$.gone') IS NULL
|
||||
AND json_extract(s.fields, '$.done') = 0;
|
||||
CREATE VIEW urgent_inbox AS
|
||||
SELECT d.id, d.business, d.task_ref, d.question, d.at
|
||||
FROM decisions d
|
||||
WHERE d.class = 'gated' AND d.blocking = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM decision_events x WHERE x.decision = d.id AND x.op IN ('resolved','withdrawn','expired'));
|
||||
CREATE TABLE task_comment_cursors (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
business TEXT NOT NULL,
|
||||
task_id INTEGER NOT NULL CHECK (task_id > 0),
|
||||
last_comment INTEGER NOT NULL CHECK (last_comment > 0)
|
||||
) STRICT;
|
||||
CREATE INDEX task_comment_cursors_latest ON task_comment_cursors (business, task_id, seq);
|
||||
CREATE TRIGGER task_comment_cursors_no_update BEFORE UPDATE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_delete BEFORE DELETE ON task_comment_cursors
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_no_replace BEFORE INSERT ON task_comment_cursors
|
||||
WHEN EXISTS (SELECT 1 FROM task_comment_cursors WHERE seq = NEW.seq)
|
||||
BEGIN SELECT RAISE(ABORT, 'task_comment_cursors is append-only'); END;
|
||||
CREATE TRIGGER task_comment_cursors_forward BEFORE INSERT ON task_comment_cursors
|
||||
WHEN NEW.last_comment <= COALESCE((SELECT last_comment FROM task_comment_cursors
|
||||
WHERE business = NEW.business AND task_id = NEW.task_id ORDER BY seq DESC LIMIT 1), 0)
|
||||
BEGIN SELECT RAISE(ABORT, 'comment cursor must advance'); END;
|
||||
CREATE TABLE projects (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo TEXT,
|
||||
created_by TEXT NOT NULL,
|
||||
UNIQUE (business, name)
|
||||
) STRICT;
|
||||
CREATE TABLE threads (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
id TEXT NOT NULL UNIQUE,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
project TEXT NOT NULL REFERENCES projects(id),
|
||||
instance TEXT NOT NULL,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
UNIQUE (project, name)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_claims (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
business TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
op TEXT NOT NULL CHECK (op IN ('claim','release','revoke')),
|
||||
holder_run TEXT NOT NULL,
|
||||
harness TEXT NOT NULL, address TEXT,
|
||||
by TEXT NOT NULL, reason TEXT,
|
||||
decision TEXT
|
||||
) STRICT;
|
||||
CREATE TABLE message_threads (
|
||||
message TEXT PRIMARY KEY REFERENCES messages(id),
|
||||
thread TEXT NOT NULL REFERENCES threads(id)
|
||||
) STRICT;
|
||||
CREATE TABLE thread_opens (
|
||||
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
at TEXT NOT NULL,
|
||||
thread TEXT NOT NULL REFERENCES threads(id),
|
||||
by TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE INDEX thread_claims_thread ON thread_claims (thread, seq);
|
||||
CREATE INDEX message_threads_thread ON message_threads (thread);
|
||||
CREATE TRIGGER threads_project_business BEFORE INSERT ON threads
|
||||
WHEN (SELECT business FROM projects WHERE id = NEW.project) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread belongs to its project''s business'); END;
|
||||
CREATE TRIGGER thread_one_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND (SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) = 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread already held'); END;
|
||||
CREATE TRIGGER thread_one_per_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'claim' AND EXISTS (SELECT 1 FROM thread_claims c WHERE c.holder_run = NEW.holder_run AND c.business = NEW.business
|
||||
AND c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = c.thread) AND c.op = 'claim')
|
||||
BEGIN SELECT RAISE(ABORT, 'run already holds a thread'); END;
|
||||
CREATE TRIGGER thread_release_by_holder BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op IN ('release','revoke') AND COALESCE((SELECT op FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1),'') <> 'claim'
|
||||
BEGIN SELECT RAISE(ABORT, 'thread is not held'); END;
|
||||
CREATE TRIGGER thread_release_same_run BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'release' AND (SELECT holder_run FROM thread_claims WHERE thread = NEW.thread ORDER BY seq DESC LIMIT 1) <> NEW.holder_run
|
||||
BEGIN SELECT RAISE(ABORT, 'only the holder releases; others revoke'); END;
|
||||
CREATE TRIGGER thread_revoke_needs_decision BEFORE INSERT ON thread_claims
|
||||
WHEN NEW.op = 'revoke' AND NEW.decision IS NULL AND NEW.reason IS NULL
|
||||
BEGIN SELECT RAISE(ABORT, 'revoke needs a resolved decision or a reason'); END;
|
||||
CREATE TRIGGER thread_claims_business BEFORE INSERT ON thread_claims
|
||||
WHEN (SELECT business FROM threads WHERE id = NEW.thread) IS NOT NEW.business
|
||||
BEGIN SELECT RAISE(ABORT, 'a thread claim names the thread''s business'); END;
|
||||
CREATE TRIGGER message_threads_match BEFORE INSERT ON message_threads
|
||||
WHEN NOT EXISTS (SELECT 1 FROM messages m JOIN threads t ON t.id = NEW.thread
|
||||
WHERE m.id = NEW.message AND m.business = t.business AND t.instance IN (m.to_role, m.from_role))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message is to or from the thread''s instance'); END;
|
||||
CREATE TRIGGER delivery_same_thread BEFORE INSERT ON deliveries
|
||||
WHEN NEW.op IN ('routed','delivered') AND (NEW.op = 'delivered' OR NEW.holder_run IS NOT NULL)
|
||||
AND EXISTS (SELECT 1 FROM message_threads mt JOIN messages m ON m.id = mt.message JOIN threads t ON t.id = mt.thread
|
||||
WHERE mt.message = NEW.message AND m.to_role = t.instance
|
||||
AND NOT EXISTS (SELECT 1 FROM thread_claims c WHERE c.seq = (SELECT max(seq) FROM thread_claims WHERE thread = t.id)
|
||||
AND c.op = 'claim' AND c.holder_run IS NEW.holder_run))
|
||||
BEGIN SELECT RAISE(ABORT, 'a threaded message goes only to its thread''s holder'); END;
|
||||
CREATE TRIGGER projects_no_update BEFORE UPDATE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_delete BEFORE DELETE ON projects BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER projects_no_replace BEFORE INSERT ON projects WHEN EXISTS (SELECT 1 FROM projects WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'projects is append-only'); END;
|
||||
CREATE TRIGGER threads_no_update BEFORE UPDATE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_delete BEFORE DELETE ON threads BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER threads_no_replace BEFORE INSERT ON threads WHEN EXISTS (SELECT 1 FROM threads WHERE seq = NEW.seq OR id = NEW.id) BEGIN SELECT RAISE(ABORT, 'threads is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_update BEFORE UPDATE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_delete BEFORE DELETE ON thread_claims BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER thread_claims_no_replace BEFORE INSERT ON thread_claims WHEN EXISTS (SELECT 1 FROM thread_claims WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_claims is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_update BEFORE UPDATE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_delete BEFORE DELETE ON message_threads BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER message_threads_no_replace BEFORE INSERT ON message_threads WHEN EXISTS (SELECT 1 FROM message_threads WHERE message = NEW.message) BEGIN SELECT RAISE(ABORT, 'message_threads is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_update BEFORE UPDATE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_delete BEFORE DELETE ON thread_opens BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
CREATE TRIGGER thread_opens_no_replace BEFORE INSERT ON thread_opens WHEN EXISTS (SELECT 1 FROM thread_opens WHERE seq = NEW.seq) BEGIN SELECT RAISE(ABORT, 'thread_opens is append-only'); END;
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
|
||||
function fixture(t, project, secretCheck = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-await-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'coder', delivery: 'coder' }, roles: {
|
||||
coder: { definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] } },
|
||||
} };
|
||||
const broker = new Broker({ store, businesses: { demo: business }, project, secretCheck });
|
||||
const cap = broker.bindLaunch({ business: 'demo', role: 'coder', run: 'run', harness: 'pi', address: 'coder' });
|
||||
const call = (verb, args = {}) => broker.request(cap, { verb, args });
|
||||
call('role.claim');
|
||||
return { broker, cap, call, store };
|
||||
}
|
||||
|
||||
test('D1: committed write with a failed receipt refuses outcome-unknown without action.allowed', async t => {
|
||||
let f, committed = false;
|
||||
f = fixture(t, { async writeRecord() {
|
||||
committed = true;
|
||||
f.call('role.release');
|
||||
return { path: 'docs/result.md', commit: 'a'.repeat(40) };
|
||||
} });
|
||||
let returned;
|
||||
await assert.rejects(async () => {
|
||||
returned = await f.call('record.write', { project: 'stack', path: 'docs/result.md', record: '# Result' });
|
||||
}, { code: 'outcome-unknown' });
|
||||
assert.equal(committed, true);
|
||||
assert.equal(returned, undefined);
|
||||
assert.deepEqual(f.store.all("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='record.write'"), []);
|
||||
});
|
||||
|
||||
test('D2: spec.read refuses without content if its role is released during the read', async t => {
|
||||
let f;
|
||||
f = fixture(t, { async readSpec() {
|
||||
f.call('role.release');
|
||||
return { content: 'CONTENT-MUST-NOT-RETURN' };
|
||||
} });
|
||||
let returned;
|
||||
await assert.rejects(async () => { returned = await f.call('spec.read', { project: 'stack' }); }, { code: 'not-holder' });
|
||||
assert.equal(returned, undefined);
|
||||
assert.ok(!JSON.stringify(f.store.all('SELECT body FROM events')).includes('CONTENT-MUST-NOT-RETURN'));
|
||||
});
|
||||
|
||||
for (const field of ['content', 'subject']) test(`D3: spec.read secret check refuses flagged ${field} without content`, async t => {
|
||||
const marker = 'FLAGGED-SPEC-VALUE';
|
||||
const result = { content: field === 'content' ? marker : '# Safe', log: [{ subject: field === 'subject' ? marker : 'Safe' }] };
|
||||
const f = fixture(t, { async readSpec() { return result; } }, value => {
|
||||
if (JSON.stringify(value).includes(marker)) throw new BusError('secret-refused');
|
||||
});
|
||||
let returned;
|
||||
await assert.rejects(async () => { returned = await f.call('spec.read', { project: 'stack' }); }, { code: 'secret-refused' });
|
||||
assert.equal(returned, undefined);
|
||||
assert.ok(!JSON.stringify(f.store.all('SELECT body FROM events')).includes(marker));
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
import { startTimeOf } from '../../seat/src/proc.mjs';
|
||||
import { initProject } from '../../project/src/index.mjs';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
|
||||
test('Broker keeps the existing sandbox client import closure: no new imports or lazy project import', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
"import { randomUUID, randomBytes } from 'node:crypto';",
|
||||
"import { loadRequirements } from '../../tasks/src/requirements.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
});
|
||||
|
||||
test('without a trusted project adapter all three async paths refuse project-not-configured', async t => {
|
||||
const f = await fixture(t, { adapter: false });
|
||||
for (const [verb, args] of [
|
||||
['spec.read', { project: 'stack' }],
|
||||
['record.write', { project: 'stack', path: 'docs/a.md', record: '# A' }],
|
||||
['decision.raise', { project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit }],
|
||||
]) await assert.rejects(f.call(f.coder, verb, args), { code: 'project-not-configured' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('real startBroker injects the project adapter for socket reads and bound-author record writes', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-runtime-'));
|
||||
let runtime;
|
||||
t.after(async () => { await runtime?.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code') } },
|
||||
arbiters: { technical: 'coder', delivery: 'coder' }, roles: { coder: {
|
||||
definition: 'coder', authority: { withinRole: ['record.write'], crossRole: [] },
|
||||
} } };
|
||||
const initialized = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
runtime = await startBroker({ dataRoot: root, businesses: { demo: business }, launches: [{
|
||||
business: 'demo', role: 'coder', run: 'runtime-run', harness: 'pi',
|
||||
pid: process.pid, startTime: startTimeOf(process.pid),
|
||||
}] });
|
||||
const client = new Client({ path: runtime.path, cap: runtime.launches[0].cap });
|
||||
await client.call('role.claim');
|
||||
const spec = await client.call('spec.read', { project: 'stack' });
|
||||
assert.equal(spec.commit, initialized.commit);
|
||||
assert.equal(spec.confirmed, true);
|
||||
const record = await client.call('record.write', { project: 'stack', path: 'docs/runtime.md', record: '# Runtime\n' });
|
||||
assert.match(record.commit, /^[0-9a-f]{40}$/);
|
||||
assert.equal(readFileSync(join(initialized.repo, record.path), 'utf8'), '# Runtime\n');
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const authority = grants => ({ withinRole: grants, crossRole: [] });
|
||||
const business = { id: 'demo', human: 'jason', arbiters: { technical: 'reviewer', delivery: 'reviewer' },
|
||||
projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } }, roles: {
|
||||
coder: { definition: 'coder', authority: authority(['message.send', 'record.write']) },
|
||||
reviewer: { definition: 'reviewer', authority: authority(['message.send']) },
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
const broker = new Broker({ store, businesses: { demo: business }, project: adapter ? {
|
||||
readSpec: context => readSpec({ ...context, dataRoot: root }),
|
||||
writeRecord: context => writeRecord({ ...context, dataRoot: root }),
|
||||
ProjectError,
|
||||
} : null });
|
||||
const agent = role => {
|
||||
const cap = broker.bindLaunch({ business: 'demo', role, run: role + '-run', harness: 'pi', address: role });
|
||||
broker.request(cap, { verb: 'role.claim' }); return cap;
|
||||
};
|
||||
const coder = agent('coder'), reviewer = agent('reviewer');
|
||||
const human = broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args = {}) => broker.request(cap, { verb, args });
|
||||
const raise = args => call(coder, 'decision.raise', { action: 'deploy', question: 'Ship?', options,
|
||||
recommendation: 'yes', blocking: false, ...args });
|
||||
const save = (content, author = 'jason') => {
|
||||
writeFileSync(join(repo, 'spec/north-star.md'), content);
|
||||
git(repo, 'add', '--', 'spec/north-star.md');
|
||||
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Spec');
|
||||
return git(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
return { root, store, business, broker, repo, commit, coder, reviewer, human, call, raise, save };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
const AT = '2026-10-15T00:00:00.000Z';
|
||||
function source3e(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'spec-migrate-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
mkdirSync(join(root, 'bus'), { mode: 0o700 });
|
||||
const path = join(root, 'bus/bus.sqlite'), db = new DatabaseSync(path);
|
||||
const ddl = readFileSync(new URL('./fixtures/schema-3e.sql', import.meta.url), 'utf8');
|
||||
assert.equal(createHash('sha256').update(ddl).digest('hex'), '21e6c091f14f818396f4d7836f381cc1389d45b2680ecaa1a00646b652c45050');
|
||||
db.exec(ddl);
|
||||
const run = (sql, ...args) => db.prepare(sql).run(...args);
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_version', '3e');
|
||||
run('INSERT INTO meta VALUES (?,?)', 'schema_digest', schemaDigest(db));
|
||||
run('INSERT INTO meta VALUES (?,?)', 'comment_cursor_epoch', AT);
|
||||
run('INSERT INTO events(id,at,business,kind,body) VALUES(?,?,?,?,?)', 'event', AT, 'demo', 'action.allowed', '{}');
|
||||
run('INSERT INTO role_claims(at,business,role,op,holder_run,harness,by) VALUES(?,?,?,?,?,?,?)', AT, 'demo', 'coder', 'claim', 'run', 'pi', 'run');
|
||||
run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
'decision', AT, 'demo', 'coder', 'run', 'gated', 'deploy', 'human', '?', '[{"key":"yes","text":"Yes"},{"key":"no","text":"No"}]', 'yes', 0);
|
||||
run('INSERT INTO decision_events(decision,at,op,by,choice) VALUES(?,?,?,?,?)', 'decision', AT, 'resolved', 'jason', 'yes');
|
||||
run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)', 'message', AT, 'demo', 'human', 'human', 'assistant', 'INFO', 'kept');
|
||||
run('INSERT INTO deliveries(message,at,op) VALUES(?,?,?)', 'message', AT, 'routed');
|
||||
run('INSERT INTO task_snapshots(at,business,task_ref,updated,digest,fields,source,role,run) VALUES(?,?,?,?,?,?,?,?,?)', AT, 'demo', 'vikunja:1/1', AT, 'a'.repeat(64), '{"bucket":1,"done":false}', 'self', 'coder', 'run');
|
||||
run('INSERT INTO task_comment_cursors(business,task_id,last_comment) VALUES(?,?,?)', 'demo', 1, 8);
|
||||
run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)', 'project', AT, 'demo', 'Work', 'stack', 'jason');
|
||||
run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)', 'thread', AT, 'demo', 'project', 'assistant', 'jason', 'One');
|
||||
run('INSERT INTO thread_claims(at,business,thread,op,holder_run,harness,by) VALUES(?,?,?,?,?,?,?)', AT, 'demo', 'thread', 'claim', 'assistant-run', 'pi', 'assistant-run');
|
||||
run('INSERT INTO message_threads VALUES(?,?)', 'message', 'thread');
|
||||
run('INSERT INTO thread_opens(at,thread,by) VALUES(?,?,?)', AT, 'thread', 'jason');
|
||||
db.close(); chmodSync(path, 0o600);
|
||||
const copy = join(root, 'untouched-3e.sqlite'); copyFileSync(path, copy);
|
||||
return { root, path, copy };
|
||||
}
|
||||
function snapshot(path, columns) {
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
try {
|
||||
columns ??= Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()
|
||||
.map(({ name }) => [name, db.prepare(`PRAGMA table_info(${name})`).all().map(c => c.name)]));
|
||||
const rows = Object.fromEntries(Object.entries(columns).map(([table, keys]) => [table,
|
||||
db.prepare(`SELECT ${keys.join(',')} FROM ${table} ORDER BY rowid`).all()
|
||||
.filter(r => table !== 'meta' || !['schema_version', 'schema_digest'].includes(r.key))]));
|
||||
return { columns, rows };
|
||||
} finally { db.close(); }
|
||||
}
|
||||
test('3e copy migrates to 3f preserving every old column/row and adding null spec citations', t => {
|
||||
const { root, path, copy } = source3e(t), before = snapshot(path), untouched = readFileSync(copy);
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
try {
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
assert.deepEqual({ ...store.get('SELECT spec_path,spec_commit FROM decisions') }, { spec_path: null, spec_commit: null });
|
||||
assert.equal(store.get('PRAGMA quick_check').quick_check, 'ok');
|
||||
assert.deepEqual(store.all('PRAGMA foreign_key_check'), []);
|
||||
} finally { store.close(); }
|
||||
assert.deepEqual(snapshot(path, before.columns).rows, before.rows);
|
||||
assert.deepEqual(readFileSync(copy), untouched);
|
||||
assert.deepEqual(snapshot(copy).rows, before.rows);
|
||||
new Store(root).close();
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { connect } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
import { serve } from '../src/server.mjs';
|
||||
import { Client } from '../src/client.mjs';
|
||||
async function socketFixture(t, options = {}) {
|
||||
const f = await fixture(t), path = join(f.root, 'bus.sock');
|
||||
const server = await serve({ broker: f.broker, path, ...options });
|
||||
t.after(() => server.close());
|
||||
return { ...f, path, client: new Client({ path, cap: f.coder }) };
|
||||
}
|
||||
|
||||
test('real socket awaits spec reads, record writes and cited raises; other requests still return normally', async t => {
|
||||
const f = await socketFixture(t);
|
||||
const spec = await f.client.call('spec.read', { project: 'stack' });
|
||||
assert.equal(spec.commit, f.commit); assert.match(spec.content, /North.star/i);
|
||||
const result = await f.client.call('record.write', { project: 'stack', path: 'docs/socket.md', record: '# Socket\n' });
|
||||
assert.match(result.commit, /^[0-9a-f]{40}$/);
|
||||
const d = await f.client.call('decision.raise', { action: 'deploy', question: 'Ship?',
|
||||
options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false,
|
||||
project: 'stack', spec_path: spec.path, spec_commit: spec.commit });
|
||||
assert.equal(f.store.get('SELECT spec_commit FROM decisions WHERE id=?', d.id).spec_commit, spec.commit);
|
||||
assert.ok(Array.isArray(await f.client.call('agents')));
|
||||
});
|
||||
|
||||
test('real socket preserves asynchronous refusal codes and disconnects its transient proof', async t => {
|
||||
let transient;
|
||||
const f = await fixture(t), path = join(f.root, 'bus.sock');
|
||||
const server = await serve({ broker: f.broker, path, authenticateHuman: () => {
|
||||
transient = f.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
return transient;
|
||||
} });
|
||||
t.after(() => server.close());
|
||||
const client = new Client({ path, cap: f.coder });
|
||||
await assert.rejects(client.call('spec.read', { project: 'missing' }), { code: 'project-not-declared' });
|
||||
await assert.rejects(new Client({ path, human: { fixture: true } }).call('spec.read', { project: 'stack' }), { code: 'agent-required' });
|
||||
assert.throws(() => f.broker.identity(transient), /unauthenticated/);
|
||||
assert.ok(Array.isArray(await client.call('agents')));
|
||||
});
|
||||
|
||||
test('two real socket writes to one repository yield one commit and one project-busy refusal', async t => {
|
||||
const f = await socketFixture(t);
|
||||
const results = await Promise.allSettled(['one', 'two'].map(name =>
|
||||
new Client({ path: f.path, cap: f.coder }).call('record.write', {
|
||||
project: 'stack', path: `docs/${name}.md`, record: `# ${name}\n`,
|
||||
})));
|
||||
assert.equal(results.filter(r => r.status === 'fulfilled').length, 1);
|
||||
const refused = results.find(r => r.status === 'rejected');
|
||||
assert.equal(refused.reason.code, 'project-busy');
|
||||
assert.equal(f.store.all("SELECT body FROM events WHERE json_extract(body,'$.action')='record.write'").length, 1);
|
||||
});
|
||||
|
||||
for (const rejects of [false, true]) test(`client disconnect during await tolerates late ${rejects ? 'refusal' : 'reply'}`, async t => {
|
||||
const f = await fixture(t), path = join(f.root, 'bus.sock');
|
||||
let release, admitted, finished;
|
||||
const holding = new Promise(resolve => { release = resolve; });
|
||||
const started = new Promise(resolve => { admitted = resolve; });
|
||||
const completed = new Promise(resolve => { finished = resolve; });
|
||||
const broker = { request(cap, request) {
|
||||
if (request.verb !== 'spec.read') return f.broker.request(cap, request);
|
||||
admitted();
|
||||
return holding.then(() => f.broker.request(cap, { verb: 'spec.read',
|
||||
args: { project: rejects ? 'missing' : 'stack' } })).finally(finished);
|
||||
} };
|
||||
const server = await serve({ broker, path });
|
||||
t.after(() => server.close());
|
||||
const socket = connect(path);
|
||||
socket.on('error', () => {});
|
||||
socket.write(JSON.stringify({ cap: f.coder, verb: 'spec.read', args: { project: 'stack' } }) + '\n');
|
||||
await started;
|
||||
const closed = new Promise(resolve => socket.once('close', resolve));
|
||||
socket.destroy(); await closed;
|
||||
release(); await completed;
|
||||
// A further socket round-trip proves the server remains usable after the late completion.
|
||||
assert.ok(Array.isArray(await new Client({ path, cap: f.coder }).call('agents')));
|
||||
});
|
||||
|
||||
|
||||
test('N-a: slow admitted record.write returns its committed result after the idle deadline', async t => {
|
||||
const f = await fixture(t), path = join(f.root, 'bus.sock');
|
||||
const broker = { async request(cap, request) {
|
||||
if (request.verb === 'record.write') await new Promise(resolve => setTimeout(resolve, 150));
|
||||
return f.broker.request(cap, request);
|
||||
} };
|
||||
const server = await serve({ broker, path, timeout: 40 });
|
||||
t.after(() => server.close());
|
||||
const written = await new Client({ path, cap: f.coder }).call('record.write', {
|
||||
project: 'stack', path: 'docs/slow.md', record: '# Slow write\n',
|
||||
});
|
||||
assert.match(written.commit, /^[0-9a-f]{40}$/);
|
||||
const receipts = f.store.all("SELECT body FROM events WHERE kind='action.allowed' AND json_extract(body,'$.action')='record.write'");
|
||||
assert.equal(receipts.length, 1);
|
||||
assert.equal(JSON.parse(receipts[0].body).commit, written.commit);
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fixture, git } from './spec-fixtures.mjs';
|
||||
|
||||
test('current citation is recorded; docs commits do not stale it and a new spec never reopens settlement', async t => {
|
||||
const f = await fixture(t);
|
||||
const d = await f.raise({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
const stored = f.store.get('SELECT spec_path,spec_commit FROM decisions WHERE id=?', d.id);
|
||||
assert.equal(stored.spec_path, 'spec/north-star.md'); assert.equal(stored.spec_commit, f.commit);
|
||||
f.call(f.human, 'decision.resolve', { id: d.id, choice: 'yes' });
|
||||
await f.call(f.coder, 'record.write', { project: 'stack', path: 'docs/design.md', record: '# Design\n' });
|
||||
await f.raise({ project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit });
|
||||
f.save('# Updated\n');
|
||||
assert.equal(f.store.get('SELECT op FROM decision_events WHERE decision=? ORDER BY seq DESC', d.id).op, 'resolved');
|
||||
assert.equal(f.store.get('SELECT spec_commit FROM decisions WHERE id=?', d.id).spec_commit, f.commit);
|
||||
assert.throws(() => f.call(f.human, 'decision.resolve', { id: d.id, choice: 'no' }), /decision-closed/);
|
||||
});
|
||||
|
||||
test('stale, unconfirmed, missing and partial spec citations refuse without inserting a decision', async t => {
|
||||
const f = await fixture(t), citation = { project: 'stack', spec_path: 'spec/north-star.md', spec_commit: f.commit };
|
||||
const current = f.save('# Current\n');
|
||||
await assert.rejects(f.raise(citation), { code: 'stale-spec' });
|
||||
f.save('# Agent\n', 'coder.coder');
|
||||
await assert.rejects(f.raise({ ...citation, spec_commit: git(f.repo, 'rev-parse', 'HEAD') }), { code: 'spec-unconfirmed' });
|
||||
for (const args of [{ project: 'stack', spec_path: citation.spec_path }, { project: 'stack', spec_commit: current },
|
||||
{ spec_path: citation.spec_path, spec_commit: current }, { ...citation, spec_path: 'docs/design.md' }])
|
||||
await assert.rejects(f.raise(args), { code: 'invalid-spec-citation' });
|
||||
git(f.repo, 'rm', '--', 'spec/north-star.md');
|
||||
git(f.repo, '-c', 'user.name=jason', '-c', '[email protected]', 'commit', '-m', 'Missing');
|
||||
await assert.rejects(f.raise(citation), { code: 'project-read-missing' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
});
|
||||
|
||||
test('uncited raises retain their synchronous path and spec read is available to any held role in its business', async t => {
|
||||
const f = await fixture(t);
|
||||
const d = f.raise({});
|
||||
assert.equal(typeof d.then, 'undefined');
|
||||
assert.equal(f.store.get('SELECT spec_commit FROM decisions WHERE id=?', d.id).spec_commit, null);
|
||||
const spec = await f.call(f.reviewer, 'spec.read', { project: 'stack', commit: f.commit });
|
||||
assert.equal(spec.commit, f.commit); assert.equal(spec.confirmed, true);
|
||||
assert.equal(spec.content, readFileSync(join(f.repo, 'spec/north-star.md'), 'utf8'));
|
||||
await assert.rejects(f.call(f.reviewer, 'spec.read', { project: 'other' }), { code: 'project-not-declared' });
|
||||
await assert.rejects(f.call(f.reviewer, 'spec.read', { project: 'stack', business: 'other' }), { code: 'invalid-request' });
|
||||
await assert.rejects(f.call(f.human, 'spec.read', { project: 'stack' }), { code: 'agent-required' });
|
||||
});
|
||||
|
||||
test('record writes bind the session author, require authority and refuse spec and impersonation paths', async t => {
|
||||
const f = await fixture(t);
|
||||
const written = await f.call(f.coder, 'record.write', { project: 'stack', path: 'docs/result.md', record: '# Result\n' });
|
||||
assert.equal(git(f.repo, 'show', '-s', '--format=%an', written.commit), 'coder.coder');
|
||||
assert.equal(git(f.repo, 'show', '--format=', '--name-only', written.commit), 'docs/result.md');
|
||||
await assert.rejects(f.call(f.coder, 'record.write', { project: 'stack', path: 'spec/north-star.md', record: '# Bad' }), { code: 'project-spec-human-only' });
|
||||
await assert.rejects(f.call(f.coder, 'record.write', { project: 'stack', path: 'docs/x.md', record: '# Bad', author: { definition: 'human', instance: 'jason' } }), { code: 'invalid-request' });
|
||||
await assert.rejects(f.call(f.reviewer, 'record.write', { project: 'stack', path: 'docs/x.md', record: '# Bad' }), { code: 'decision-required' });
|
||||
const event = f.store.all("SELECT body FROM events WHERE kind='action.allowed'").map(r => JSON.parse(r.body)).find(r => r.action === 'record.write');
|
||||
assert.equal(event.commit, written.commit); assert.equal(event.path, 'docs/result.md');
|
||||
});
|
||||
@@ -49,7 +49,7 @@ async function database3d(t) {
|
||||
const source = readFileSync(here('../src/store.mjs'), 'utf8');
|
||||
const pinned = source
|
||||
.replace(/export const SCHEMA_SHA256 = '[a-f0-9]+';/, `export const SCHEMA_SHA256 = '${createHash('sha256').update(ddl3d).digest('hex')}';`)
|
||||
.replace("export const SCHEMA_VERSION = '3e';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace("export const SCHEMA_VERSION = '3f';", "export const SCHEMA_VERSION = '3d';")
|
||||
.replace(/\}, \{\n {2}from: '3d',[\s\S]*?\}\];\nconst timedTables/, '}];\nconst timedTables');
|
||||
assert.ok(!pinned.includes('3d-3e'), 'the 3d engine has no 3d→3e step');
|
||||
writeFileSync(join(moduleRoot, 'src/store.mjs'), pinned);
|
||||
@@ -95,7 +95,7 @@ test('REQ-THREAD-1 REQ-ORG-2: the 3d→3e migration adds the thread tables and l
|
||||
assert.throws(() => new Store(root), /schema-mismatch/);
|
||||
const store = new Store(root, { migrateSchema: true });
|
||||
t.after(() => store.close());
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3e');
|
||||
assert.equal(store.get("SELECT value FROM meta WHERE key='schema_version'").value, '3f');
|
||||
for (const table of ['projects', 'threads', 'thread_claims', 'message_threads', 'thread_opens'])
|
||||
assert.equal(store.get(`SELECT count(*) n FROM ${table}`).n, 0, table);
|
||||
// Parked: the undelivered ones are counted per business and role, and none is changed or given a thread.
|
||||
@@ -155,5 +155,5 @@ test('REQ-THREAD-1: the offline command prints the kept-message counts before th
|
||||
const command = new URL('../src/migrate-schema.mjs', import.meta.url).pathname;
|
||||
const migrated = spawnSync(process.execPath, [command, root], { encoding: 'utf8' });
|
||||
assert.equal(migrated.status, 0, migrated.stderr);
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3e ready', ''].join('\n'));
|
||||
assert.equal(migrated.stdout, [...REPORT, 'bus schema 3f ready', ''].join('\n'));
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ export const ACTIONS = Object.freeze([
|
||||
"git.merge.protected",
|
||||
"review.request",
|
||||
"review.verdict",
|
||||
"record.write",
|
||||
"message.send",
|
||||
"message.external",
|
||||
"role.launch",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The assistant role (REQ-ASSIST-1, row 119). The role file is policy: it
|
||||
// holds message.send within role and nothing else, no credentials, and tools
|
||||
// holds message.send and record.write within role, no credentials, and tools
|
||||
// as read-only as the chief of staff's, plus the web grant (REQ-HARN-2, vikunja:19/295). A widened file fails here.
|
||||
|
||||
import { test } from "node:test";
|
||||
@@ -12,7 +12,7 @@ import { businessDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mj
|
||||
const APPROVED = Object.freeze({
|
||||
tools: ["read", "grep", "find", "ls", "web"],
|
||||
network: "api-only",
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send"]), crossRole: Object.freeze([]) }),
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "record.write"]), crossRole: Object.freeze([]) }),
|
||||
credentials: Object.freeze([]),
|
||||
// REQ-ASSIST-2: the user and the chief of staff, both ways. The broker enforces these lists (row 120).
|
||||
messaging: Object.freeze({
|
||||
@@ -67,15 +67,15 @@ test("the shipped file is exactly the approved list", () => {
|
||||
assert.deepEqual(rest, { roleVersion: 2, name: "assistant", contract: "assistant.md", ...APPROVED });
|
||||
});
|
||||
|
||||
test("the assistant's authority is message.send within role and nothing else", () => {
|
||||
test("the assistant's authority is message.send and record.write within role", () => {
|
||||
const role = loadRole(REPO_ROLES, "assistant");
|
||||
assert.deepEqual(role.authority, { withinRole: ["message.send"], crossRole: [] });
|
||||
assert.deepEqual(role.authority, { withinRole: ["message.send", "record.write"], crossRole: [] });
|
||||
assert.deepEqual(role.credentials, []);
|
||||
const record = resolved(loadFixture());
|
||||
assert.deepEqual(record.limits.authority, { withinRole: ["message.send"], crossRole: [] });
|
||||
assert.deepEqual(record.limits.authority, { withinRole: ["message.send", "record.write"], crossRole: [] });
|
||||
assert.deepEqual(record.credentials, {});
|
||||
for (const action of ACTIONS) {
|
||||
assert.equal(classify(record, action), action === "message.send" ? "within" : "gated", action);
|
||||
assert.equal(classify(record, action), ["message.send", "record.write"].includes(action) ? "within" : "gated", action);
|
||||
}
|
||||
for (const action of ["task.create", "task.assign", "role.launch"]) assert.equal(classify(record, action), "gated", action);
|
||||
});
|
||||
|
||||
@@ -13,11 +13,11 @@ import { THREAD_IDLE_SECONDS } from "../src/business.mjs";
|
||||
import { businessDoc, launchDoc, REPO_ROLES, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
|
||||
// Decision 104's list plus role.launch (decision 139) plus role.end and
|
||||
// organization.change (row 123, REQ-COS-1). A role file that differs from it fails.
|
||||
// organization.change (row 123, REQ-COS-1) and record.write (row 138). A role file that differs from it fails.
|
||||
const APPROVED = Object.freeze({
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change"]), crossRole: Object.freeze([]) }),
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write"]), crossRole: Object.freeze([]) }),
|
||||
credentials: Object.freeze([]),
|
||||
});
|
||||
|
||||
@@ -84,9 +84,9 @@ test("the resolved record matches the approved list, so a widened file fails", (
|
||||
assert.deepEqual(record.credentials, {});
|
||||
});
|
||||
|
||||
test("the chief of staff holds role.launch, message.send, role.end and organization.change; every other action is gated", () => {
|
||||
test("the chief of staff holds role.launch, message.send, role.end, organization.change and record.write; every other action is gated", () => {
|
||||
const record = resolved(loadFixture());
|
||||
const held = ["message.send", "role.launch", "role.end", "organization.change"];
|
||||
const held = ["message.send", "role.launch", "role.end", "organization.change", "record.write"];
|
||||
for (const action of ACTIONS) {
|
||||
assert.equal(classify(record, action), held.includes(action) ? "within" : "gated", action);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// The five C-suite roles (REQ-ROLE-4, REQ-ROLE-1, vikunja:19/301; Mos Q123). Each role file is
|
||||
// policy at least privilege: read-only tools, message.send within role and nothing else, no
|
||||
// policy at least privilege: read-only tools, message.send and record.write within role, no
|
||||
// credentials. Only the chief of staff launches them (REQ-COS-1, REQ-LAUNCH-1). A widened file
|
||||
// fails here. Fixtures live in a temp directory and nothing reads the real config.
|
||||
import { test } from "node:test";
|
||||
@@ -13,7 +13,7 @@ const CSUITE = ["chief-executive-officer", "chief-financial-officer", "chief-inf
|
||||
const APPROVED = Object.freeze({
|
||||
tools: ["read", "grep", "find", "ls"],
|
||||
network: "api-only",
|
||||
authority: { withinRole: ["message.send"], crossRole: [] },
|
||||
authority: { withinRole: ["message.send", "record.write"], crossRole: [] },
|
||||
credentials: [],
|
||||
// Least privilege (vikunja:19/306): an advisory role talks to the user, the chief of staff, the pm
|
||||
// and the cto, so it can't send work to the coder or reviewer around the pm.
|
||||
@@ -57,7 +57,7 @@ test("an organization file that declares them passes, and each resolves to least
|
||||
assert.deepEqual(record.limits, { tools: APPROVED.tools, network: "api-only", authority: APPROVED.authority });
|
||||
assert.deepEqual(record.credentials, {});
|
||||
assert.equal(record.launch, null);
|
||||
for (const action of ACTIONS) assert.equal(classify(record, action), action === "message.send" ? "within" : "gated", `${name} ${action}`);
|
||||
for (const action of ACTIONS) assert.equal(classify(record, action), ["message.send", "record.write"].includes(action) ? "within" : "gated", `${name} ${action}`);
|
||||
assert.ok(!record.limits.tools.includes("web") && !record.limits.tools.includes("bash") && !record.limits.tools.includes("write"), name);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ACTIONS, GATED_ONLY } from '../src/vocabulary.mjs';
|
||||
import { loadRole } from '../src/index.mjs';
|
||||
import { REPO_ROLES } from './helpers.mjs';
|
||||
const ROLES = ['assistant', 'chief-executive-officer', 'chief-financial-officer',
|
||||
'chief-information-officer', 'chief-information-security-officer', 'chief-marketing-officer',
|
||||
'chief-of-staff', 'coder', 'cto', 'pm', 'reviewer'];
|
||||
test('record.write is within-role for the eleven approved v2 agents, never gated-only', () => {
|
||||
assert.ok(ACTIONS.includes('record.write'));
|
||||
assert.ok(!GATED_ONLY.includes('record.write'));
|
||||
for (const name of ROLES) {
|
||||
const role = loadRole(REPO_ROLES, name);
|
||||
assert.equal(role.roleVersion, 2);
|
||||
assert.ok(role.authority.withinRole.includes('record.write'), name);
|
||||
assert.ok(!role.authority.crossRole.includes('record.write'), name);
|
||||
}
|
||||
});
|
||||
@@ -307,3 +307,17 @@ These are limits, not bugs, and nothing in this package claims otherwise.
|
||||
hides it from other sessions. It holds no secret.
|
||||
- **Pi adapter paths** with spaces break its unquoted `-e` and skill
|
||||
splitting; the launcher's paths don't contain spaces.
|
||||
|
||||
## File-backed project records (row138)
|
||||
|
||||
Every agent gets read_spec(project,commit?), which returns the committed
|
||||
north-star text and path history. A role holding record.write also gets
|
||||
write_record(project,path,record,decision?), for validated docs/requirements/
|
||||
decisions committed as its bound instance. The broker supplies author metadata;
|
||||
there is no author argument and no agent spec writer. A direct attempt to write
|
||||
spec/ refuses even if a caller bypasses the offered tool schema.
|
||||
|
||||
raise_decision accepts optional project, spec_path and spec_commit. Cite both
|
||||
spec fields together, using the current confirmed file commit returned by
|
||||
read_spec, not the repository HEAD. Uncited raises remain valid. Later human
|
||||
spec commits do not reopen decisions already settled.
|
||||
|
||||
@@ -178,6 +178,20 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
url: str("The https url to fetch", { maxLength: 2048 }),
|
||||
}, ["url"]));
|
||||
}
|
||||
add("read_spec", "spec.read", null, "Read a project's committed north-star spec and its file history. commit is an optional full reachable commit ID. The file commit, rather than HEAD, is what a decision cites. confirmed says the file commit was authored by the business's human.", obj({
|
||||
project: str("Project ID from the business file"),
|
||||
commit: str("Optional full historical commit ID", { pattern: "^[0-9a-f]{40}$" }),
|
||||
}, ["project"]));
|
||||
if (held.has("record.write")) {
|
||||
add("write_record", "record.write", "record.write", "Validate and commit one project document, requirement or decision as your bound role instance. The broker supplies the author. Agents never write spec/; pending human edits or another writer cause a refusal.", obj({
|
||||
project: str("Project ID from the business file"),
|
||||
path: str("Named docs Markdown or requirement/decision JSON path", {
|
||||
pattern: "^(docs/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.md|(requirements|decisions)/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\\.json)$",
|
||||
}),
|
||||
record: { type: ["string", "object"], description: "Markdown string or a JSON record matching the project's package-owned schema" },
|
||||
decision: COMMON.decision,
|
||||
}, ["project", "path", "record"]));
|
||||
}
|
||||
add("raise_decision", "decision.raise", null, "Ask for a decision before a gated or cross-role action, or when you need a choice made. The answer arrives as a message.", obj({
|
||||
action: str("The action the decision is about", { enum: [...ACTIONS] }),
|
||||
question: str("The question, with enough context to answer it and the reason for your recommendation"),
|
||||
@@ -188,6 +202,9 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
approvalChoice: str("The option key that approves the action. It defaults to yes, so pass it whenever no option key is yes"),
|
||||
task_ref: TASK_REF,
|
||||
requirement_ref: str("Requirement id"),
|
||||
project: str("Project ID from the business file; required with a spec citation"),
|
||||
spec_path: str("Cite only this north-star path; send spec_commit with it", { const: "spec/north-star.md" }),
|
||||
spec_commit: str("The current confirmed file commit from read_spec; send spec_path with it", { pattern: "^[0-9a-f]{40}$" }),
|
||||
}, ["action", "question", "options", "recommendation", "blocking"]));
|
||||
if (arbiter) {
|
||||
add("resolve_decision", "decision.resolve", null, "Resolve a decision routed to you.", obj({
|
||||
|
||||
@@ -77,7 +77,7 @@ test("a chief-of-staff bundle lists launch among the bus tools and the manifest'
|
||||
const b = build(t, r);
|
||||
const prompt = readFileSync(b.files.prompt, "utf8");
|
||||
assert.match(prompt, /Role instance: chief \(definition chief-of-staff\)\. Run: run-1\./);
|
||||
assert.match(prompt, /Bus tools: send_message, launch, end_run, change_organization, raise_decision, /);
|
||||
assert.match(prompt, /Bus tools: send_message, launch, end_run, change_organization, read_spec, write_record, raise_decision, /);
|
||||
assert.ok(b.manifest.typed.includes("launch"));
|
||||
assert.deepEqual(JSON.parse(readFileSync(b.files.policy, "utf8")).needs, []);
|
||||
});
|
||||
@@ -88,9 +88,9 @@ test("a chief-of-staff bundle lists end_run and change_organization as typed and
|
||||
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
|
||||
const b = build(t, r);
|
||||
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "change_organization", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "change_organization", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
|
||||
assert.deepEqual(b.manifest.untyped, [], harness);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, change_organization, raise_decision, /);
|
||||
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, change_organization, read_spec, write_record, raise_decision, /);
|
||||
assert.doesNotMatch(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { typedTools, callTool } from '../src/tools.mjs';
|
||||
import { resolvedFor, fakeToolSocket } from './helpers.mjs';
|
||||
import { fixture } from '../../bus/tests/spec-fixtures.mjs';
|
||||
|
||||
test('every role sees read_spec, record writer follows its grant, citation schemas stay strict', t => {
|
||||
for (const role of ['coder', 'reviewer', 'pm', 'chief']) {
|
||||
const tools = typedTools({ resolved: resolvedFor(t, role) });
|
||||
const read = tools.find(t => t.name === 'read_spec'), write = tools.find(t => t.name === 'write_record');
|
||||
assert.deepEqual([read.verb, read.action], ['spec.read', null]);
|
||||
assert.deepEqual(read.parameters.required, ['project']);
|
||||
assert.deepEqual(Object.keys(read.parameters.properties), ['project', 'commit']);
|
||||
assert.equal(read.parameters.additionalProperties, false);
|
||||
assert.deepEqual([write.verb, write.action], ['record.write', 'record.write']);
|
||||
assert.deepEqual(write.parameters.required, ['project', 'path', 'record']);
|
||||
assert.equal(write.parameters.additionalProperties, false);
|
||||
assert.equal(Object.hasOwn(write.parameters.properties, 'author'), false);
|
||||
assert.equal(new RegExp(write.parameters.properties.path.pattern).test('spec/north-star.md'), false);
|
||||
assert.equal(new RegExp(write.parameters.properties.path.pattern).test('docs/design.md'), true);
|
||||
const raise = tools.find(t => t.name === 'raise_decision').parameters;
|
||||
assert.equal(raise.additionalProperties, false);
|
||||
assert.equal(raise.properties.spec_path.const, 'spec/north-star.md');
|
||||
assert.equal(new RegExp(raise.properties.spec_commit.pattern).test('a'.repeat(40)), true);
|
||||
assert.equal(raise.required.includes('spec_commit'), false);
|
||||
}
|
||||
const restricted = resolvedFor(t, 'coder', doc => { doc.roles.coder.vars['limits.authority'] = ['message.send']; });
|
||||
const tools = typedTools({ resolved: restricted });
|
||||
assert.ok(tools.some(t => t.name === 'read_spec'));
|
||||
assert.ok(!tools.some(t => t.name === 'write_record'));
|
||||
});
|
||||
|
||||
test('typed socket forwards read content/history and agent record writes, and cannot write a spec', async t => {
|
||||
const f = await fixture(t);
|
||||
const tools = typedTools({ resolved: { definition: 'coder', limits: { tools: [], authority: {
|
||||
withinRole: ['record.write'], crossRole: [],
|
||||
} } } });
|
||||
const byName = new Map(tools.map(tool => [tool.name, tool]));
|
||||
const server = await fakeToolSocket(t, f.root, (name, args) =>
|
||||
f.broker.request(f.coder, { verb: byName.get(name).verb, args }));
|
||||
const spec = await callTool(server.path, 'read_spec', { project: 'stack' });
|
||||
assert.equal(spec.commit, f.commit); assert.equal(spec.log[0].commit, f.commit);
|
||||
assert.match(spec.content, /North.star/i);
|
||||
const record = await callTool(server.path, 'write_record', { project: 'stack', path: 'docs/tool.md', record: '# Tool\n' });
|
||||
assert.match(record.commit, /^[0-9a-f]{40}$/);
|
||||
await assert.rejects(callTool(server.path, 'write_record', { project: 'stack', path: 'spec/north-star.md', record: '# Bad' }), /project-spec-human-only/);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
|
||||
"send_message", "request_staff",
|
||||
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
|
||||
"task_update_assigned", "task_close",
|
||||
"raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
|
||||
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
|
||||
]);
|
||||
// Row 119 (#1613): only the chief of staff launches (REQ-COS-1).
|
||||
assert.equal(pm.launch, null);
|
||||
@@ -21,7 +21,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
|
||||
const create = tools.find((x) => x.name === "task_create");
|
||||
assert.deepEqual({ ...create.parameters.properties.project, description: undefined }, { type: "integer", minimum: 1, description: undefined });
|
||||
assert.deepEqual(create.parameters.required, ["title", "request", "requirement"]);
|
||||
assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["task_create"]);
|
||||
assert.deepEqual(tools.filter((x) => x.parameters.properties.project).map((x) => x.name), ["task_create", "read_spec", "write_record", "raise_decision"]);
|
||||
for (const tool of tools) {
|
||||
assert.equal(tool.parameters.type, "object");
|
||||
assert.equal(tool.parameters.additionalProperties, false);
|
||||
@@ -33,7 +33,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
|
||||
test("the chief of staff gets launch, send_message, end_run and change_organization, and no task verbs", (t) => {
|
||||
const chief = resolvedFor(t, "chief");
|
||||
const tools = typedTools({ resolved: chief, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "change_organization", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "change_organization", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
const launch = tools.find((x) => x.name === "launch");
|
||||
assert.equal(launch.verb, "launch");
|
||||
assert.equal(launch.action, "role.launch");
|
||||
@@ -106,11 +106,11 @@ test("a role without role.end or organization.change gets neither tool, and each
|
||||
assert.deepEqual(names(typedTools({ resolved: only("organization.change") })).filter((n) => n === "end_run" || n === "change_organization"), ["change_organization"]);
|
||||
});
|
||||
|
||||
// Row 119 (#1613): the assistant holds message.send alone.
|
||||
// Row 119 gave the assistant message.send; row 138 adds record.write.
|
||||
test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => {
|
||||
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
|
||||
const tools = typedTools({ resolved: assistant, tracker: true });
|
||||
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.equal(assistant.launch, null);
|
||||
assert.deepEqual(untypedActions(assistant, tools), []);
|
||||
});
|
||||
@@ -144,7 +144,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with
|
||||
test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => {
|
||||
const coder = resolvedFor(t, "coder");
|
||||
const tools = typedTools({ resolved: coder });
|
||||
assert.deepEqual(names(tools), ["send_message", "request_review", "raise_decision", "list_agents", "inbox", "trail"]);
|
||||
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail"]);
|
||||
// Held actions without a tool are listed, so the manifest shows them. With
|
||||
// no clone to push from, push_working is one of them.
|
||||
assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "task.scope.change"]);
|
||||
@@ -154,7 +154,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
|
||||
const coder = resolvedFor(t, "coder");
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
|
||||
"send_message", "task_scope_change", "task_update_assigned", "request_review",
|
||||
"raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -167,7 +167,7 @@ test("launch only when the business's launch block names the instance as launche
|
||||
|
||||
test("an action outside the instance's authority has no tool", (t) => {
|
||||
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
});
|
||||
|
||||
// Row 93 (#1587): the review loop's tools, by authority, and the git ones only with a clone.
|
||||
@@ -176,7 +176,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
|
||||
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack", "site"] });
|
||||
assert.deepEqual(names(tools), [
|
||||
"send_message", "task_scope_change", "task_update_assigned", "push_working", "request_review",
|
||||
"raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
|
||||
]);
|
||||
assert.deepEqual(untypedActions(coder, tools), []);
|
||||
const push = tools.find((x) => x.name === "push_working");
|
||||
@@ -193,7 +193,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
|
||||
|
||||
const reviewer = resolvedFor(t, "reviewer");
|
||||
const rtools = typedTools({ resolved: reviewer, tracker: true, projects: ["stack"] });
|
||||
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
|
||||
const fetch = rtools.find((x) => x.name === "fetch_review");
|
||||
assert.deepEqual([fetch.verb, fetch.action], ["working.fetch", null]);
|
||||
assert.deepEqual(fetch.parameters.required, ["task_ref", "request"]);
|
||||
|
||||
@@ -117,3 +117,29 @@ callers must coordinate simultaneous human saves. No destructive repair, push,
|
||||
remote creation, role grant, spec-confirmation mechanism or live action ships.
|
||||
|
||||
Tests: `node --test packages/project/tests/*.test.mjs`. Fixtures only.
|
||||
|
||||
## Committed file reads (row 138)
|
||||
|
||||
`readProjectFile({business,project,dataRoot,path,commit?})` reads only
|
||||
spec/north-star.md or a named docs/NAME.md; `readSpec({business,project,dataRoot,commit?})`
|
||||
fixes the spec path. Both are async and read-only, share repository/path checks,
|
||||
refuse remotes and unsafe Git metadata, and validate committed project.json
|
||||
identity. Working-tree edits do not replace committed content. A supplied full
|
||||
SHA-1 must be a commit reachable from a captured HEAD.
|
||||
|
||||
The result is {repo,path,head,revision,commit,content,author,log,truncated}.
|
||||
commit is the last commit changing the file at revision, not necessarily HEAD;
|
||||
content preserves exact valid UTF-8 including trailing newlines, up to 1 MiB with
|
||||
no NUL. Log has at most 100 entries, newest first, each
|
||||
{commit,author:{name,email},at,subject}; at is canonical UTC. truncated says more
|
||||
entries exist. Reads reject nonregular Git blobs, missing paths and invalid
|
||||
content. History output is separately bounded to 256 KiB.
|
||||
|
||||
readSpec adds confirmed=true exactly when the file commit author.name equals
|
||||
business.human. An agent-authored latest change stays unconfirmed. This is local
|
||||
Git provenance, not a cryptographic identity proof. Broker citation validation
|
||||
compares a fresh current snapshot and never uses an older read as authority.
|
||||
Read errors are ProjectError: project-read-path, project-read-commit,
|
||||
project-read-missing, project-read-content, project-invalid, plus existing
|
||||
project-id/path/not-declared/repository/remote/git codes. No write or spec save
|
||||
API is added; row 148 owns the human editor writer and its stale-base check.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { lstatSync, readdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { gitEnv, runGit } from '../../seat/src/checkout.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { gitEnv, runGit, GIT_LIMITS } from '../../seat/src/checkout.mjs';
|
||||
import { ProjectError } from './paths.mjs';
|
||||
|
||||
const SAFE = ['-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false', '-c', 'core.ignorestat=false',
|
||||
@@ -53,3 +54,33 @@ export async function requireClean(repo) {
|
||||
}
|
||||
export const identity = name => ({ GIT_AUTHOR_NAME: name, GIT_AUTHOR_EMAIL: `${name}@mosaic.invalid`,
|
||||
GIT_COMMITTER_NAME: name, GIT_COMMITTER_EMAIL: `${name}@mosaic.invalid` });
|
||||
|
||||
// Blob/log reads preserve bytes and bound memory before decoding untrusted Git data.
|
||||
export async function gitBytes(repo, args, maxBytes) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('git', [...SAFE, '-C', repo, ...args], {
|
||||
env: { ...gitEnv(), GIT_CEILING_DIRECTORIES: dirname(repo) },
|
||||
detached: true, stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let size = 0, failed = false, settled = false;
|
||||
const chunks = [];
|
||||
const kill = () => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} };
|
||||
const timer = setTimeout(() => { failed = true; kill(); }, GIT_LIMITS.timeoutMs);
|
||||
const finish = (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
if (failed || code !== 0) reject(new ProjectError('project-git', 'bounded local Git read refused'));
|
||||
else resolve(Buffer.concat(chunks));
|
||||
};
|
||||
child.stdout.on('data', chunk => {
|
||||
size += chunk.length;
|
||||
if (size > maxBytes) { failed = true; kill(); }
|
||||
else chunks.push(chunk);
|
||||
});
|
||||
// Drain stderr without retaining repository-controlled text.
|
||||
child.stderr.on('data', () => {});
|
||||
child.on('error', () => finish(-1));
|
||||
child.on('close', finish);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -230,3 +230,5 @@ export async function writeRecord({ path, record, author, ...context }) {
|
||||
if (lstatSync(lock).ino === lockStat.ino) unlinkSync(lock);
|
||||
}
|
||||
}
|
||||
|
||||
export { readProjectFile, readSpec } from './read.mjs';
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { projectRepo, ProjectError } from './paths.mjs';
|
||||
import { git, gitBytes, inspectGit } from './git.mjs';
|
||||
import { validateRecord } from './validate.mjs';
|
||||
const MAX_BYTES = 1024 * 1024;
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const fail = (code, message) => { throw new ProjectError(code, message); };
|
||||
function argumentsFor(args, allowed) {
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args) || Object.keys(args).some(k => !allowed.includes(k)))
|
||||
fail('project-read-path', 'unknown read arguments');
|
||||
}
|
||||
function text(bytes) {
|
||||
try {
|
||||
const value = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
||||
if (value.includes('\0')) fail('project-read-content', 'NUL in blob');
|
||||
return value;
|
||||
} catch { fail('project-read-content', 'expected bounded UTF-8 without NUL'); }
|
||||
}
|
||||
async function blob(repo, revision, path) {
|
||||
const entry = await git(repo, ['ls-tree', '-z', revision, '--', path]);
|
||||
if (!entry) fail('project-read-missing', 'file absent at revision');
|
||||
const match = /^(100644|100755) blob ([0-9a-f]{40})\t([^\0]+)\0$/.exec(entry);
|
||||
if (!match || match[3] !== path) fail('project-read-content', 'expected regular Git blob');
|
||||
const size = Number(await git(repo, ['cat-file', '-s', match[2]]));
|
||||
if (!Number.isSafeInteger(size) || size < 0 || size > MAX_BYTES)
|
||||
fail('project-read-content', 'blob exceeds size bound');
|
||||
return text(await gitBytes(repo, ['cat-file', 'blob', match[2]], MAX_BYTES));
|
||||
}
|
||||
export async function readProjectFile(args) {
|
||||
argumentsFor(args, ['business', 'project', 'dataRoot', 'path', 'commit']);
|
||||
const { business, project, dataRoot, path, commit } = args;
|
||||
if (typeof path !== 'string' || !(path === 'spec/north-star.md' || /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(path)))
|
||||
fail('project-read-path', 'expected spec/north-star.md or a named Markdown doc');
|
||||
const repo = projectRepo({ business, project, dataRoot });
|
||||
await inspectGit(repo);
|
||||
let head;
|
||||
try { head = await git(repo, ['rev-parse', '--verify', 'HEAD^{commit}']); }
|
||||
catch { fail('project-read-commit', 'repository has no readable HEAD commit'); }
|
||||
const revision = commit === undefined ? head : commit;
|
||||
if (typeof revision !== 'string' || !SHA.test(revision)) fail('project-read-commit', 'expected full commit ID');
|
||||
try {
|
||||
if (await git(repo, ['cat-file', '-t', revision]) !== 'commit') fail('project-read-commit', 'not a commit');
|
||||
await git(repo, ['merge-base', '--is-ancestor', revision, head]);
|
||||
} catch { fail('project-read-commit', 'commit is not reachable from HEAD'); }
|
||||
let metadata;
|
||||
try { metadata = JSON.parse(await blob(repo, revision, 'project.json')); }
|
||||
catch { fail('project-invalid', 'missing or invalid committed project metadata'); }
|
||||
if (validateRecord('project', metadata).length || metadata.business !== business.id || metadata.project !== project)
|
||||
fail('project-invalid', 'committed project identity does not match context');
|
||||
const content = await blob(repo, revision, path);
|
||||
const bytes = await gitBytes(repo, ['log', '-z', '--max-count=101',
|
||||
'--format=%H%x00%an%x00%ae%x00%aI%x00%s', revision, '--', path], 256 * 1024);
|
||||
let fields;
|
||||
try { fields = new TextDecoder('utf-8', { fatal: true }).decode(bytes).split('\0'); }
|
||||
catch { fail('project-git', 'invalid UTF-8 Git history'); }
|
||||
if (fields.pop() !== '' || !fields.length || fields.length % 5 !== 0)
|
||||
fail('project-git', 'invalid Git history');
|
||||
const log = [];
|
||||
for (let i = 0; i < fields.length; i += 5) {
|
||||
const [commit, name, email, at, subject] = fields.slice(i, i + 5);
|
||||
if (!SHA.test(commit) || !Number.isFinite(Date.parse(at))) fail('project-git', 'invalid Git history');
|
||||
log.push({ commit, author: { name, email }, at: new Date(at).toISOString(), subject });
|
||||
}
|
||||
return { repo, path, head, revision, commit: log[0].commit, content, author: log[0].author,
|
||||
log: log.slice(0, 100), truncated: log.length > 100 };
|
||||
}
|
||||
export async function readSpec(args) {
|
||||
argumentsFor(args, ['business', 'project', 'dataRoot', 'commit']);
|
||||
const result = await readProjectFile({ ...args, path: 'spec/north-star.md' });
|
||||
return { ...result, confirmed: result.author.name === args.business.human };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync, readFileSync, symlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import * as api from '../src/index.mjs';
|
||||
const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
async function fixture(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'project-spec-read-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const context = { business: { id: 'demo', human: 'jason', projects: {
|
||||
stack: { root: join(root, 'code'), repo: join(root, 'records') },
|
||||
} }, project: 'stack', dataRoot: root };
|
||||
const { repo, commit } = await api.initProject(context);
|
||||
const save = (path, bytes, author = 'jason') => {
|
||||
writeFileSync(join(repo, path), bytes);
|
||||
git(repo, 'add', '--', path);
|
||||
git(repo, '-c', `user.name=${author}`, '-c', '[email protected]', 'commit', '-m', 'Save ' + path);
|
||||
return git(repo, 'rev-parse', 'HEAD');
|
||||
};
|
||||
return { context, repo, commit, save, root };
|
||||
}
|
||||
|
||||
test('spec reads exact committed content and its path commit/log, not dirty bytes or a later docs commit', async t => {
|
||||
const { context, repo, save, commit } = await fixture(t);
|
||||
const spec = save('spec/north-star.md', '# Spec\n\nKeep this.\n\n');
|
||||
const docs = save('docs/notes.md', '# Docs\n');
|
||||
writeFileSync(join(repo, 'spec/north-star.md'), '# Unsaved human edit\n');
|
||||
const result = await api.readSpec(context);
|
||||
assert.equal(result.content, '# Spec\n\nKeep this.\n\n');
|
||||
assert.equal(result.repo, repo);
|
||||
assert.equal(result.path, 'spec/north-star.md');
|
||||
assert.equal(result.head, docs); assert.equal(result.revision, docs); assert.equal(result.commit, spec);
|
||||
assert.equal(result.confirmed, true);
|
||||
assert.deepEqual(result.author, { name: 'jason', email: '[email protected]' });
|
||||
assert.deepEqual(result.log.map(r => r.commit), [spec, commit]);
|
||||
assert.equal(result.truncated, false);
|
||||
assert.equal(readFileSync(join(repo, 'spec/north-star.md'), 'utf8'), '# Unsaved human edit\n');
|
||||
assert.equal(git(repo, 'rev-parse', 'HEAD'), docs);
|
||||
for (const row of result.log) assert.equal(new Date(row.at).toISOString(), row.at);
|
||||
const historical = await api.readSpec({ ...context, commit });
|
||||
assert.equal(historical.commit, commit);
|
||||
assert.deepEqual(historical.log.map(r => r.commit), [commit]);
|
||||
assert.equal(historical.content, readFileSync(new URL('../templates/north-star.md', import.meta.url), 'utf8'));
|
||||
});
|
||||
|
||||
test('latest agent spec edit is visible but never confirmed or replaced by an earlier human version', async t => {
|
||||
const { context, save } = await fixture(t);
|
||||
const commit = save('spec/north-star.md', '# Agent\n', 'coder.builder');
|
||||
const result = await api.readSpec(context);
|
||||
assert.equal(result.commit, commit); assert.equal(result.confirmed, false);
|
||||
assert.equal(result.author.name, 'coder.builder');
|
||||
});
|
||||
|
||||
test('shared file reader exposes committed docs and refuses path/revision injection or another history', async t => {
|
||||
const { context, repo, save } = await fixture(t);
|
||||
const commit = save('docs/design.md', '# Design\n');
|
||||
assert.equal((await api.readProjectFile({ ...context, path: 'docs/design.md' })).commit, commit);
|
||||
for (const path of ['../outside.md', 'spec/other.md', '/spec/north-star.md', 'docs/a/b.md', 'requirements/R.json'])
|
||||
await assert.rejects(api.readProjectFile({ ...context, path }), { code: 'project-read-path' });
|
||||
for (const commit of ['HEAD', '--help', 'a'.repeat(40), 'abc', git(repo, 'write-tree')])
|
||||
await assert.rejects(api.readSpec({ ...context, commit }), { code: 'project-read-commit' });
|
||||
const orphan = git(repo, '-c', 'user.name=jason', '-c', '[email protected]',
|
||||
'commit-tree', git(repo, 'write-tree'), '-m', 'Unreachable');
|
||||
await assert.rejects(api.readSpec({ ...context, commit: orphan }), { code: 'project-read-commit' });
|
||||
});
|
||||
|
||||
test('reader rejects missing paths, links, invalid UTF-8, NUL and oversized committed blobs', async t => {
|
||||
const { context, repo, save } = await fixture(t);
|
||||
await assert.rejects(api.readProjectFile({ ...context, path: 'docs/missing.md' }), { code: 'project-read-missing' });
|
||||
for (const bytes of [Buffer.from([0xff]), 'x\0y', 'x'.repeat(1024 * 1024 + 1)]) {
|
||||
save('spec/north-star.md', bytes);
|
||||
await assert.rejects(api.readSpec(context), { code: 'project-read-content' });
|
||||
}
|
||||
rmSync(join(repo, 'spec/north-star.md'));
|
||||
symlinkSync('../docs/README.md', join(repo, 'spec/north-star.md'));
|
||||
git(repo, 'add', '--', 'spec/north-star.md');
|
||||
git(repo, '-c', 'user.name=jason', '-c', '[email protected]', 'commit', '-m', 'Linked spec');
|
||||
await assert.rejects(api.readSpec(context), { code: 'project-read-content' });
|
||||
});
|
||||
|
||||
test('reader refuses mismatched committed project identity, unsafe config and unknown argument keys', async t => {
|
||||
const { context, repo, save } = await fixture(t);
|
||||
await assert.rejects(api.readSpec({ ...context, path: 'docs/README.md' }), { code: 'project-read-path' });
|
||||
await assert.rejects(api.readSpec({ ...context, project: 'other' }), { code: 'project-not-declared' });
|
||||
const metadata = JSON.parse(readFileSync(join(repo, 'project.json'))); metadata.business = 'other';
|
||||
save('project.json', JSON.stringify(metadata));
|
||||
await assert.rejects(api.readSpec(context), { code: 'project-invalid' });
|
||||
git(repo, 'remote', 'add', 'origin', 'https://fixture.invalid/no-network');
|
||||
await assert.rejects(api.readSpec(context), { code: 'project-remote' });
|
||||
});
|
||||
|
||||
test('path history is bounded at 100 and explicitly marks truncation', async t => {
|
||||
const { context, save } = await fixture(t);
|
||||
for (let i = 0; i < 101; i++) save('spec/north-star.md', `# Spec ${i}\n`);
|
||||
const result = await api.readSpec(context);
|
||||
assert.equal(result.log.length, 100);
|
||||
assert.equal(result.truncated, true);
|
||||
assert.equal(result.log[0].commit, result.commit);
|
||||
});
|
||||
|
||||
test('committed UTF-8 BOM, Unicode and trailing newlines are returned without normalization', async t => {
|
||||
const { context, save } = await fixture(t);
|
||||
const value = '\uFEFF# North star 🧭\r\n\r\n';
|
||||
save('spec/north-star.md', Buffer.from(value));
|
||||
assert.equal((await api.readSpec(context)).content, value);
|
||||
});
|
||||
|
||||
|
||||
test('D4: spec log refuses a commit subject larger than its 256 KiB read bound', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'spec-log-bound-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const business = { id: 'demo', human: 'jason', projects: { stack: { root: join(root, 'code'), repo: join(root, 'records') } } };
|
||||
const { repo } = await api.initProject({ business, project: 'stack', dataRoot: root });
|
||||
const message = join(root, 'commit-message.txt');
|
||||
writeFileSync(message, 'X'.repeat(256 * 1024 + 1) + '\n');
|
||||
writeFileSync(join(repo, 'spec/north-star.md'), '# Small spec\n');
|
||||
execFileSync('git', ['-C', repo, 'add', '--', 'spec/north-star.md']);
|
||||
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false',
|
||||
'-c', 'user.name=jason', '-c', '[email protected]', '-C', repo, 'commit', '-F', message],
|
||||
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
await assert.rejects(api.readSpec({ business, project: 'stack', dataRoot: root }), { code: 'project-git' });
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls", "web"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send"],
|
||||
"withinRole": ["message.send", "record.write"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send"],
|
||||
"withinRole": ["message.send", "record.write"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send"],
|
||||
"withinRole": ["message.send", "record.write"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send"],
|
||||
"withinRole": ["message.send", "record.write"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send"],
|
||||
"withinRole": ["message.send", "record.write"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send"],
|
||||
"withinRole": ["message.send", "record.write"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": [],
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["message.send", "role.launch", "role.end", "organization.change"],
|
||||
"withinRole": ["message.send", "role.launch", "role.end", "organization.change", "record.write"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": []
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send"],
|
||||
"withinRole": ["task.update.assigned", "git.push.working", "review.request", "message.send", "record.write"],
|
||||
"crossRole": ["task.scope.change"]
|
||||
},
|
||||
"credentials": [
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "write", "edit", "bash", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["review.request", "task.update.assigned", "message.send", "decision.resolve.technical"],
|
||||
"withinRole": ["review.request", "task.update.assigned", "message.send", "decision.resolve.technical", "record.write"],
|
||||
"crossRole": ["task.scope.change"]
|
||||
},
|
||||
"credentials": [
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "write", "edit", "grep", "find", "ls"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "message.send"],
|
||||
"withinRole": ["task.create", "task.assign", "task.reassign", "task.schedule", "task.update.assigned", "task.close", "message.send", "record.write"],
|
||||
"crossRole": ["task.priority.change", "task.scope.change"]
|
||||
},
|
||||
"credentials": [
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"tools": ["read", "grep", "find", "ls", "bash"],
|
||||
"network": "api-only",
|
||||
"authority": {
|
||||
"withinRole": ["review.verdict", "message.send", "task.update.assigned"],
|
||||
"withinRole": ["review.verdict", "message.send", "task.update.assigned", "record.write"],
|
||||
"crossRole": []
|
||||
},
|
||||
"credentials": [
|
||||
|
||||
Reference in New Issue
Block a user