diff --git a/agents/rocko/work/queue-219/BUILD.md b/agents/rocko/work/queue-219/BUILD.md index c352659a3..5b5fd82bb 100644 --- a/agents/rocko/work/queue-219/BUILD.md +++ b/agents/rocko/work/queue-219/BUILD.md @@ -284,3 +284,26 @@ is refreshed to the same digest for the next claimant/review handoff. Local commit only; Dewey checks mos1420 items1-3 before push/restart, and Sage names a gap/CLEAR after294 round4. No runs after this edit. Any further red, including later lines in this test, returns to Mos through Sage. + +## Rebase round r3 after row294: sage-3250 / sage-3254 + +Rebased five commits from4a9c536e8..5f2ef92e5 onto +80793d824e05baaef4820f4be7790aba55971b6f, new branch +rocko/row219-on-80793d824. No conflicts and no manual product edits. +Row294's shared chat-http status refusals, chat owner-only channel-settings +transport, and serve's user rebinding/route fencing/static federation assets +remain. Row219 still forwards the selected instance over the bound caller; +its metadata route follows the listener/session bus after row294 rebinding. + +r3/range-diff.txt records all five pairs: four are '='; the first is '!' +only because createChat's surrounding signature now includes owner=null. +No hunk body changed. Per-path patch IDs and row294 carry delta IDs are in +r3/path-patch-ids.json and r3/294-carry-patch-ids.json; conflict list is empty. +r3/overlap-readers.json lists exact source readers and whole tests via their +test-helper import closure, refreshed on this base. Root and r3 manifest, +base and build.patch are refreshed. Old r2 packet is historical and unchanged. + +Dewey's checked mos1418 and mos1420 test edits stand; this handoff requests +rebase-only static review. No tests, Node, mutants, reader gate audit or push +performed while preparing this local rebase. Full no-carry restart waits for +Sage's CLEAR. Any new red returns to Mos through Sage before fixes. diff --git a/agents/rocko/work/queue-219/base.txt b/agents/rocko/work/queue-219/base.txt index 1d519a39a..f17c9be70 100644 --- a/agents/rocko/work/queue-219/base.txt +++ b/agents/rocko/work/queue-219/base.txt @@ -1 +1 @@ -4a9c536e861f1d9f9a018019b3ac4519d65eccf0 +80793d824e05baaef4820f4be7790aba55971b6f diff --git a/agents/rocko/work/queue-219/build.patch b/agents/rocko/work/queue-219/build.patch index b34097ed7..a78ae86b5 100644 --- a/agents/rocko/work/queue-219/build.patch +++ b/agents/rocko/work/queue-219/build.patch @@ -415,10 +415,10 @@ index 0d207a0a0..fa3adb866 100644 + assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1])); +}); diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs -index c7770726d..396b42f3c 100644 +index 7980c1442..63d3ca974 100644 --- a/packages/webui/src/chat-http.mjs +++ b/packages/webui/src/chat-http.mjs -@@ -78,7 +78,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev +@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev (Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error(); action = 'createProject'; args = v; } else if (path === '/api/chat/threads') { @@ -429,10 +429,10 @@ index c7770726d..396b42f3c 100644 } else if (match?.[2] === 'messages') { if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error(); diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs -index 018574ad4..65b89dab3 100644 +index 1b0a41dce..5a1ed8bf1 100644 --- a/packages/webui/src/chat.mjs +++ b/packages/webui/src/chat.mjs -@@ -20,7 +20,7 @@ export function createChat({ call }) { +@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) { projects: () => channel('project.list', {}), createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }), threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }), @@ -535,7 +535,7 @@ index a6b805c1b..15066bdc6 100644 } }); diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs -index 288871d0c..d9d27a062 100644 +index cd51a0b6e..56fcdd4e1 100644 --- a/packages/webui/src/serve.mjs +++ b/packages/webui/src/serve.mjs @@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from @@ -546,7 +546,7 @@ index 288871d0c..d9d27a062 100644 import { chatHTTP } from './chat-http.mjs'; import { processingHTTP } from './processing-http.mjs'; -@@ -705,6 +706,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF +@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); return busSend(req, res, { send, preview: signer, log }); } diff --git a/agents/rocko/work/queue-219/candidate-manifest.sha256 b/agents/rocko/work/queue-219/candidate-manifest.sha256 index 841f95bd5..6a46760c2 100644 --- a/agents/rocko/work/queue-219/candidate-manifest.sha256 +++ b/agents/rocko/work/queue-219/candidate-manifest.sha256 @@ -5,10 +5,10 @@ de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/t b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs 2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs 7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs -cbfd54e6b375561d1e4679ee8d78a3285f970d9a7198cec9d22a06510a36afba packages/webui/src/chat-http.mjs -f49a49c6ec69783190d3f5eaf34316cb4d2c44d10d2ee9336fe1383207afefff packages/webui/src/chat.mjs +39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs +77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs 8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js -a597cc4902a873fe15650dacfce14dbc897db26310a7b6a24c293287add946c8 packages/webui/src/serve.mjs +443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs 2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs 7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs diff --git a/agents/rocko/work/queue-219/r3/294-carry-patch-ids.json b/agents/rocko/work/queue-219/r3/294-carry-patch-ids.json new file mode 100644 index 000000000..287a30ec8 --- /dev/null +++ b/agents/rocko/work/queue-219/r3/294-carry-patch-ids.json @@ -0,0 +1,17 @@ +[ + { + "path": "packages/webui/src/chat-http.mjs", + "baseDelta": "463a78d259c86d16e4c081bd2336f666477463b8", + "tipDelta": "463a78d259c86d16e4c081bd2336f666477463b8" + }, + { + "path": "packages/webui/src/chat.mjs", + "baseDelta": "8d90d1eac27308bd93f40c2c27ff9393c670a948", + "tipDelta": "8d90d1eac27308bd93f40c2c27ff9393c670a948" + }, + { + "path": "packages/webui/src/serve.mjs", + "baseDelta": "c4b842ace73a63a7ef91645d1e30f4610e69c928", + "tipDelta": "c4b842ace73a63a7ef91645d1e30f4610e69c928" + } +] diff --git a/agents/rocko/work/queue-219/r3/BUILD.md b/agents/rocko/work/queue-219/r3/BUILD.md new file mode 100644 index 000000000..2d512262f --- /dev/null +++ b/agents/rocko/work/queue-219/r3/BUILD.md @@ -0,0 +1,5 @@ +# Row219 rebase round r3 + +See ../BUILD.md rebase round section. Base80793d824; no conflicts. +Range-diff, per-path identities, row294 carry identities and exact test +reader inventory are adjacent. No tests run. Dewey checks rebase only. diff --git a/agents/rocko/work/queue-219/r3/base.txt b/agents/rocko/work/queue-219/r3/base.txt new file mode 100644 index 000000000..f17c9be70 --- /dev/null +++ b/agents/rocko/work/queue-219/r3/base.txt @@ -0,0 +1 @@ +80793d824e05baaef4820f4be7790aba55971b6f diff --git a/agents/rocko/work/queue-219/r3/build.patch b/agents/rocko/work/queue-219/r3/build.patch new file mode 100644 index 000000000..a78ae86b5 --- /dev/null +++ b/agents/rocko/work/queue-219/r3/build.patch @@ -0,0 +1,788 @@ +diff --git a/packages/bus/src/broker.mjs b/packages/bus/src/broker.mjs +index d76085739..11061c778 100644 +--- a/packages/bus/src/broker.mjs ++++ b/packages/bus/src/broker.mjs +@@ -4,7 +4,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr + import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs'; + import { loadRequirements } from '../../tasks/src/requirements.mjs'; + import { MERGE, LandingRefusal, readLanding } from './landing.mjs'; +-import { ID_PATTERN, THREAD_DEFINITION, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs'; ++import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers } from '../../business/src/vocabulary.mjs'; + + // Kept closed while S1 lands; its vocabulary is the integration authority. + export const ACTIONS = Object.freeze([ +@@ -314,7 +314,7 @@ const enrollBinding = (enroll) => ({ + }); + // r6 E3: the installer line's comment, then the tag's 32 hex digits. + const INSTALLER_COMMENT = 'mosaic-installer-'; +-// Row 121 (#1615): an instance of THREAD_DEFINITION (vocabulary.mjs since row 155) talks with the ++// Row 121 (#1615): an instance of THREAD_DEFINITIONS (vocabulary.mjs since row 213) talks with the + // user in threads. Its runs claim a thread, not the role, and every message to or from it names its + // thread (REQ-MSG-1). + // Row 146 (#1640): the sender of the bus's notice to launch.by. A role instance name matches the +@@ -598,7 +598,7 @@ export class Broker { + ); + } + #threaded(business, role) { +- return this.#business(business).roles[role]?.definition === THREAD_DEFINITION; ++ return THREAD_DEFINITIONS.includes(this.#business(business).roles[role]?.definition); + } + // Row 264a (#1769): a claim holds one session of a thread, and each session has its own holder. + #sessionHolder(session) { +@@ -6068,24 +6068,27 @@ export class Broker { + .all('SELECT id,at,name,repo FROM projects WHERE business=? ORDER BY seq', s.business) + .filter((p) => !s.user || this.#granted(s, 'project.list', p.id, null)); + } +- // A thread belongs to the user who made it and to the business's one thread instance. ++ // A thread belongs to the user who made it and to one declared thread instance. + // Row 177 (#1675): a tester's thread is owned by `tester`, so neither reaches the other's. + case 'thread.create': { + if (!s.tester && !s.user) this.#human(s); +- keys(a, ['project', 'name'], ['project', 'name']); ++ keys(a, ['project', 'name', 'instance'], ['project', 'name']); + if (!name(a.name)) fail('invalid-request'); ++ const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r)); ++ const instance = a.instance === undefined ++ ? (instances.length === 1 ? instances[0] : null) ++ : identifier(a.instance); + // FED R3 (design 3.5): a user's grant must cover the project and the thread instance, checked + // before the project is looked up, so an ungranted id reads the same whether it exists or not. + if (s.user) { +- const threaded = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r)); +- if (threaded.length !== 1 || !this.#granted(s, 'thread.create', identifier(a.project), threaded[0])) fail('access-refused'); ++ if (!instance || !instances.includes(instance) || !this.#granted(s, 'thread.create', identifier(a.project), instance)) ++ fail('access-refused'); + } + if (!this.#store.get('SELECT 1 FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)) + fail('project-not-found'); +- // The bus never picks between instances; the business file declares exactly one. +- const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r)); +- if (!instances.length) fail('thread-instance-missing'); +- if (instances.length > 1) fail('thread-instance-ambiguous'); ++ // The human selects an instance; omission is compatible only with one candidate. ++ if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing'); ++ if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous'); + if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists'); + const tid = randomUUID(); + this.#store.run( +@@ -6094,13 +6097,13 @@ export class Broker { + this.#now(), + s.business, + a.project, +- instances[0], ++ instance, + this.#owner(s), + a.name, + ); + // Row 264a (#1769, D3): every thread has a default session from the start. +- this.#newSession(s, { id: tid, business: s.business, instance: instances[0], owner: this.#owner(s) }, 'default', 'default', true); +- return { id: tid, instance: instances[0] }; ++ this.#newSession(s, { id: tid, business: s.business, instance, owner: this.#owner(s) }, 'default', 'default', true); ++ return { id: tid, instance }; + } + // Row 177 (#1675): each lists its own threads. The human's list leaves out the tester's, + // and the tester's lists only its own (row 175 listed it the business human's). +diff --git a/packages/bus/tests/creative.test.mjs b/packages/bus/tests/creative.test.mjs +index 6cfbf1cd6..6d421ba19 100644 +--- a/packages/bus/tests/creative.test.mjs ++++ b/packages/bus/tests/creative.test.mjs +@@ -44,15 +44,18 @@ function setup(t) { + const caps = {}; + caps.human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); + const project = b.request(caps.human, { verb: 'project.create', args: { name: 'demo' } }).id; +- const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main' } }).id; ++ const thread = b.request(caps.human, { verb: 'thread.create', args: { project, name: 'main', instance: 'assistant' } }).id; ++ const threads = { assistant: thread }; + for (const role of Object.keys(roles)) { +- caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(role === 'assistant' ? { thread } : {}) }); ++ if (['bean', 'quill'].includes(role)) ++ threads[role] = b.request(caps.human, { verb: 'thread.create', args: { project, name: role, instance: role } }).id; ++ caps[role] = b.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', ...(threads[role] ? { thread: threads[role] } : {}) }); + b.request(caps[role], { verb: 'role.claim' }); + } + caps.tester = b.bindTester({ business: 'demo' }); + const call = (who, verb, args = {}) => b.request(caps[who], { verb, args }); + const count = (sql) => store.get(sql).n; +- return { call, count, store }; ++ return { call, count, store, threads }; + } + + const raise = { action: 'policy.change', question: 'Change?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'yes', blocking: false }; +@@ -72,7 +75,7 @@ test('M6: a creative raise refuses decision-not-allowed, cited or not, and no de + }); + + test('a creative instance sends only to the user and hears only from the user', (t) => { +- const { call, count } = setup(t); ++ const { call, count, threads } = setup(t); + for (const who of ['bean', 'quill']) { + call(who, 'message.send', { to: 'human', body: 'hello' }); + for (const to of ['chief', 'assistant', 'pm', 'bean', 'quill'].filter((n) => n !== who)) { +@@ -81,7 +84,7 @@ test('a creative instance sends only to the user and hears only from the user', + for (const from of ['chief', 'pm']) { + assert.throws(() => call(from, 'message.send', { to: who, body: 'hello' }), { code: 'message-sender-refused' }, `${from} to ${who}`); + } +- call('human', 'message.send', { to: who, body: 'hello' }); ++ call('human', 'thread.send', { thread: threads[who], body: 'hello' }); + } + assert.equal(count("SELECT count(*) n FROM messages WHERE from_role IN ('bean','quill')"), 2); + assert.equal(count("SELECT count(*) n FROM messages WHERE to_role IN ('bean','quill')"), 2); +diff --git a/packages/bus/tests/threads.test.mjs b/packages/bus/tests/threads.test.mjs +index 8b31da9f9..1410ec3fa 100644 +--- a/packages/bus/tests/threads.test.mjs ++++ b/packages/bus/tests/threads.test.mjs +@@ -601,3 +601,42 @@ test('project.create keeps unrelated names with and without a repository', t => + { id: linked.id, name: 'work', repo: 'stack' }, + ]); + }); ++ ++test('C2-09a: explicit creative instances bind independent threads, default sessions and holders', t => { ++ const creative = ['companion', 'ideas', 'research', 'proofreader', 'fact-checker', 'continuity']; ++ const extra = Object.fromEntries(creative.map((definition, i) => [`writer${i}`, { definition, authority: { withinRole: ['message.send'], crossRole: [] } }])); ++ const ctx = setup(t, demo({ roles: { ...roles, ...extra }, definitions: { ++ ...definitions, ...Object.fromEntries(creative.map(definition => [definition, { messaging: shipped(definition).messaging }])), ++ } })); ++ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id; ++ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'ambiguous' }), { code: 'thread-instance-ambiguous' }); ++ for (const [instance, role] of Object.entries(extra)) { ++ const receipt = ctx.call('human', 'thread.create', { project, name: instance, instance }); ++ assert.equal(receipt.instance, instance); ++ assert.equal(ctx.store.get('SELECT instance FROM threads WHERE id=?', receipt.id).instance, instance); ++ assert.equal(ctx.store.get('SELECT instance FROM sessions WHERE thread=?', receipt.id).instance, instance); ++ ctx.launch(instance, instance, { thread: receipt.id }); ++ assert.equal(ctx.claim(instance).thread, receipt.id); ++ const sent = ctx.call('human', 'thread.send', { thread: receipt.id, body: `For ${instance}` }); ++ const read = ctx.call(instance, 'message.receive'); ++ assert.deepEqual(read.map(m => m.id), [sent.id], role.definition); ++ assert.equal(ctx.threadOf(sent.id), receipt.id); ++ assert.equal(ctx.store.get('SELECT count(*) n FROM role_claims WHERE role=?', instance).n, 0); ++ assert.throws(() => ctx.call('human', 'message.send', { to: instance, body: 'No implicit thread' }), ++ { code: 'thread-required-use-thread-send' }); ++ } ++ assert.equal(ctx.count('SELECT count(*) n FROM threads'), creative.length); ++}); ++ ++test('C2-09a: explicit missing, non-thread and malformed selectors create no thread or session', t => { ++ const ctx = setup(t); ++ const project = ctx.call('human', 'project.create', { name: 'Writing' }).id; ++ for (const instance of ['not-declared', 'pm', 'coder']) ++ assert.throws(() => ctx.call('human', 'thread.create', { project, name: instance, instance }), { code: 'thread-instance-missing' }); ++ for (const instance of [null, 7, {}, [], '', 'bad name']) ++ assert.throws(() => ctx.call('human', 'thread.create', { project, name: 'bad', instance }), { code: 'invalid-request' }); ++ assert.equal(ctx.count('SELECT count(*) n FROM threads'), 0); ++ assert.equal(ctx.count('SELECT count(*) n FROM sessions'), 0); ++ const legacy = ctx.call('human', 'thread.create', { project, name: 'Default' }); ++ assert.equal(legacy.instance, 'assistant'); ++}); +diff --git a/packages/bus/tests/user-principal.test.mjs b/packages/bus/tests/user-principal.test.mjs +index 4dcd1ebeb..8471ddf85 100644 +--- a/packages/bus/tests/user-principal.test.mjs ++++ b/packages/bus/tests/user-principal.test.mjs +@@ -23,7 +23,7 @@ const grant = (over = {}) => ({ + }); + + // `processing` stands in for the halt switch, as lifecycle-verbs.test.mjs's T15 does. +-function setup(t, { grants = [grant()], processing = null } = {}) { ++function setup(t, { grants = [grant()], processing = null, threadRoles = {} } = {}) { + const root = mkdtempSync(join(tmpdir(), 'bus-user-principal-')); + const store = new Store(root); + t.after(() => { +@@ -40,6 +40,7 @@ function setup(t, { grants = [grant()], processing = null } = {}) { + roles: { + chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } }, + assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, ++ ...threadRoles, + }, + definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging } }, + }, +@@ -394,3 +395,21 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g + test('R3 r4: a non-function access throws before the store is read', () => { + assert.throws(() => new Broker({ store: null, businesses: { demo: {} }, access: 'access.json' }), /access must be a function/); + }); ++ ++test('C2-09a: naming an instance cannot bypass its collaborator grant or disclose an ungranted project', t => { ++ const ctx = setup(t, { threadRoles: { bean: { definition: 'companion', authority: { withinRole: ['message.send'], crossRole: [] } } } }); ++ const project = ctx.call('human', 'project.create', { name: 'shared' }).id; ++ const privateProject = ctx.call('human', 'project.create', { name: 'private' }).id; ++ ctx.access.doc.grants = [grant({ projects: [project] })]; ++ ctx.user('alice'); ++ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'blocked bean', instance: 'bean' }), 'access-refused'); ++ for (const id of [privateProject, '00000000-0000-4000-8000-000000000000']) ++ assert.equal(ctx.code('alice', 'thread.create', { project: id, name: 'blocked project', instance: 'assistant' }), 'access-refused'); ++ assert.equal(ctx.store.get('SELECT count(*) n FROM threads').n, 0); ++ const allowed = ctx.call('alice', 'thread.create', { project, name: 'assistant work', instance: 'assistant' }); ++ assert.equal(allowed.instance, 'assistant'); ++ ctx.access.doc.grants = [grant({ projects: [project], instances: ['bean'] })]; ++ const bean = ctx.call('alice', 'thread.create', { project, name: 'bean work', instance: 'bean' }); ++ assert.equal(bean.instance, 'bean'); ++ assert.equal(ctx.code('alice', 'thread.create', { project, name: 'old instance', instance: 'assistant' }), 'access-refused'); ++}); +diff --git a/packages/cli/src/launcher.mjs b/packages/cli/src/launcher.mjs +index bfe55b61e..d8248284c 100644 +--- a/packages/cli/src/launcher.mjs ++++ b/packages/cli/src/launcher.mjs +@@ -53,7 +53,7 @@ + // session.json names it as gitSocket. + // + // Thread sessions (row 121, #1615). A thread instance (an instance whose +-// role definition is the assistant) runs once per thread, never on its own: ++// role definition is in THREAD_DEFINITIONS) runs once per thread, never on its own: + // a launch from a session or --pm refuses thread-required. Every tick + // (threadTickMs) the host asks the broker for the business's threads + // (launch op threadsState) and: +@@ -139,6 +139,7 @@ import { createServer } from "node:net"; + import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync } from "node:fs"; + import { dirname, join, relative } from "node:path"; + import { configDir, loadBusiness, resolveInstance, systemVars } from "../../business/src/index.mjs"; ++import { THREAD_DEFINITIONS } from "../../business/src/vocabulary.mjs"; + import { limitHit } from "../../business/src/launch.mjs"; + import { BundleError, buildBundle, hasFallback, readSkills, sessionModel } from "../../harness/src/bundle.mjs"; + import { createLoader } from "../../plugins/src/loader.mjs"; +@@ -176,8 +177,6 @@ const STOP_TIMEOUT_MS = 30000; + const LINE_MAX = 4096; + // How often the host asks for the threads' state; a poll period, not a limit. + const THREAD_TICK_MS = 2000; +-// The broker's thread instance definition (packages/bus/src/broker.mjs). +-const THREAD_DEFINITION = "assistant"; + // Row 194: the broker's LIFECYCLE_REQUEST_MS. A regenerate's old run must be gone by then. + const LIFECYCLE_REQUEST_MS = 60000; + +@@ -331,7 +330,7 @@ export function createLauncher({ + try { b=loadBusiness(host.business,{dir:configDir(env),rolesDir:rolesDir(env)}); } catch(e){throw new Refusal("organization-refused",e.message);} + if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`); + if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed"); +- const threaded = b.roles[instance]?.definition === THREAD_DEFINITION; ++ const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition); + if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`); + if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`); + if (threaded && !session) throw new Refusal("session-required", `${instance} runs for one session of a thread`); +@@ -997,7 +996,7 @@ export function createLauncher({ + function ending(r) { + const runs = r.runs ?? []; + const at = Date.parse(r.at); +- const threaded = b.roles[r.instance]?.definition === THREAD_DEFINITION; ++ const threaded = THREAD_DEFINITIONS.includes(b.roles[r.instance]?.definition); + return registry.sessions.filter((s) => s.business === b.id && (runs.includes(s.run) || + (!threaded && s.instance === r.instance && (spawnedAt.get(s.run) ?? Date.parse(s.startedAt)) <= at))); + } +diff --git a/packages/cli/tests/launcher.test.mjs b/packages/cli/tests/launcher.test.mjs +index 2202c7753..baabd843e 100644 +--- a/packages/cli/tests/launcher.test.mjs ++++ b/packages/cli/tests/launcher.test.mjs +@@ -773,21 +773,34 @@ test("row 213 a creative instance's persona is read from /personas: o + const dir = join(given.root, "config", "personas", "acme"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(join(dir, "bean.md"), "Not the pinned text.\n", { mode: 0o600 }); +- const { f, host, launcher } = await setup(t, undefined, given); +- // The chief of staff holds role.launch; a test chief session bound to this process asks. +- const chief = await host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) }); +- await new Client({ path: host.path, cap: chief.cap }).call("role.claim"); +- // The bundle's refusal comes back as launch-failed, as webPlan's does, with its reason in the log. +- assert.deepEqual(await launcher.fromSession(chief.cap, "bean"), { ok: false, error: "launch-failed" }); +- const [e] = log(f); +- assert.deepEqual([e.event, e.code, log(f).length], ["refused", "launch-failed", 1]); ++ // Row 219: creative instances launch from their thread, not from a chief's standalone request. ++ // The mismatch occurs before bindLaunch, so only the host's thread-state snapshot is a fixture. ++ const thread = 'persona-thread'; ++ const SESSION = 'persona-session'; ++ const { f, launcher } = await setup(t, undefined, given, { wrap: host => ({ ++ ...host, ++ op: message => message.op === 'threadsState' ++ ? Promise.resolve([{ thread, session: SESSION, instance: 'bean', holder: null, pending: 1 }]) ++ : host.op(message), ++ }) }); ++ // The thread path records row 270's run-start count before the bundle's persona refusal. ++ await launcher.tick(); ++ const runs = readdirSync(join(f.dataRoot, "launches", "acme")); ++ const entries = log(f); ++ assert.equal(entries.length, 2); ++ const { at, ...compactions } = entries[0]; ++ assert.deepEqual(compactions, { event: 'compactions', instance: 'bean', thread, session: SESSION, run: runs[0], count: 0, known: true, reason: null }); ++ const e = entries[1]; ++ assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]); ++ assert.equal(e.thread, thread); + assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`); + assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2")); + assert.deepEqual(readSessions(f.dataRoot), []); + // The run directory exists by then (BUILD.md residual); the persona is read before any bundle file. +- const runs = readdirSync(join(f.dataRoot, "launches", "acme")); + assert.equal(runs.length, 1); +- assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), ["session"]); ++ assert.deepEqual(readdirSync(join(f.dataRoot, "launches", "acme", runs[0])), []); ++ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "sessions", SESSION, "session")), true); ++ assert.equal(existsSync(join(f.dataRoot, "threads", "acme", thread, "session")), false); + }); + + test("row 64 on Claude Code a role skill that isn't enabled refuses the same way, before anything starts", { timeout: 30000 }, async (t) => { +diff --git a/packages/cli/tests/thread-launch.test.mjs b/packages/cli/tests/thread-launch.test.mjs +index 0d207a0a0..fa3adb866 100644 +--- a/packages/cli/tests/thread-launch.test.mjs ++++ b/packages/cli/tests/thread-launch.test.mjs +@@ -396,6 +396,22 @@ test("REQ-THREAD-1 REQ-LAUNCH-1 the chief can't launch the assistant outside a t + assert.deepEqual(launchLog(f).map((e) => [e.event, e.instance, e.code]), [["refused", "assistant", "thread-required"]]); + }); + ++// Admission refuses before bundle construction or a sandbox is needed. ++test('C2-09a: fromSession refuses a creative instance outside a thread without a sandbox', { timeout: 30000 }, async t => { ++ const prepared = prepare(t, { edit: doc => { ++ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' }; ++ doc.launch.instances.push('bean'); ++ return doc; ++ } }); ++ const { f } = prepared; ++ const host = await start(t, prepared); ++ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-bean-refusal', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) }); ++ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim'); ++ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' }); ++ assert.deepEqual(readSessions(f.dataRoot), []); ++ assert.deepEqual(launchLog(f).map(e => [e.event, e.instance, e.code]), [['refused', 'bean', 'thread-required']]); ++}); ++ + // Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the token-limit check switched on, with the + // assistant's limit at 0, so it is at its limit before any turn. The real check runs through the + // bus's plug-in loader. +@@ -443,3 +459,73 @@ test("REQ-LIMIT-4 a thread run at its token limit stops at the end of its turn: + assert.deepEqual(threadRuns(f, A), []); + assert.equal(host.runtime.threadsState("acme").find((x) => x.thread === A).holder, null); + }); ++ ++test('C2-09a: creative thread messages start distinct runs and keep each instance history separate', { skip, timeout: 90000 }, async t => { ++ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => { ++ const vars = { ...doc.roles.assistant.vars }; ++ delete doc.roles.assistant; ++ doc.roles.bean = { definition: 'companion', vars: { ...vars } }; ++ doc.roles.chet = { definition: 'continuity', vars: { ...vars } }; ++ doc.launch.instances = ['coder', 'bean', 'chet']; ++ return doc; ++ } }); ++ const host = await start(t, prepared), { f } = prepared; ++ const project = host.call('project.create', { name: 'Writing' }).id; ++ const bean = host.call('thread.create', { project, name: 'Bean', instance: 'bean' }).id; ++ const chet = host.call('thread.create', { project, name: 'Chet', instance: 'chet' }).id; ++ const send = async (thread, instance, body) => { ++ const message = host.call('thread.send', { thread, body }); ++ const outcome = await until(() => host.call('thread.messages', { thread }).find(m => m.in_reply_to === message.id) || ++ launchLog(f).find(row => row.thread === thread && row.event === 'refused')); ++ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`); ++ const reply = outcome; ++ assert.equal(reply.from_role, instance); ++ assert.equal(reply.to_role, 'human'); ++ return JSON.parse(reply.body.match(/THREAD (\{.*\})/)[1]); ++ }; ++ assert.deepEqual((await send(bean, 'bean', 'Bean input')).before, []); ++ assert.deepEqual((await send(chet, 'chet', 'Chet input')).before, []); ++ assert.deepEqual((await send(bean, 'bean', 'Bean again')).before, ['Bean input']); ++ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', bean, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === bean).session, 'session', 'history'), 'utf8'), 'Bean input\nBean again\n'); ++ assert.equal(readFileSync(join(f.dataRoot, 'threads', 'acme', chet, 'sessions', host.runtime.threadsState('acme').find(x => x.thread === chet).session, 'session', 'history'), 'utf8'), 'Chet input\n'); ++ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', bean, 'session')), false); ++ assert.equal(existsSync(join(f.dataRoot, 'threads', 'acme', chet, 'session')), false); ++ const launches = launchLog(f).filter(row => row.event === 'launch'); ++ assert.ok(launches.some(row => row.instance === 'bean' && row.thread === bean)); ++ assert.ok(launches.some(row => row.instance === 'chet' && row.thread === chet)); ++ const chief = await host.host.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-creative-test', harness: 'pi', pid: process.pid, startTime: startTimeOf(process.pid) }); ++ await new Client({ path: host.host.path, cap: chief.cap }).call('role.claim'); ++ assert.deepEqual(await host.launcher.fromSession(chief.cap, 'bean'), { ok: false, error: 'thread-required' }); ++}); ++ ++test('C2-09a: terminating one creative thread does not signal an unselected peer of the same instance', { skip, timeout: 90000 }, async t => { ++ const prepared = prepare(t, { sonnet: 2, idle: 60, edit: doc => { ++ doc.roles.bean = { ...doc.roles.assistant, definition: 'companion' }; ++ delete doc.roles.assistant; ++ doc.launch.instances = ['coder', 'bean']; ++ return doc; ++ } }); ++ let requests = []; ++ const host = await start(t, prepared, { ops: () => ({ ++ lifecycleRequests: () => requests, ++ lifecycleTake: () => ({ ...requests[0], go: true }), ++ lifecycleDone: () => { requests = []; return {}; }, ++ }) }); ++ const { f } = prepared; ++ const project = host.call('project.create', { name: 'Writing' }).id; ++ const threads = ['one', 'two'].map(name => host.call('thread.create', { project, name, instance: 'bean' }).id); ++ for (const thread of threads) { ++ const message = host.call('thread.send', { thread, body: 'Stay in this thread' }); ++ const outcome = await until(() => host.call('thread.messages', { thread }).find(row => row.in_reply_to === message.id) || ++ launchLog(f).find(row => row.thread === thread && row.event === 'refused')); ++ assert.notEqual(outcome.event, 'refused', `creative thread launch refused: ${outcome.code}`); ++ } ++ const runs = threads.map(thread => launchLog(f).find(row => row.thread === thread && row.event === 'launch').run); ++ requests = [{ request: 'one-thread-end', state: 'open', business: 'acme', action: 'lifecycle.end', ++ instance: 'bean', runs: [runs[0]], by: f.doc.human, at: new Date(Date.now() + 1000).toISOString() }]; ++ await host.launcher.tick(); ++ await until(() => launchLog(f).some(row => row.event === 'lifecycle-end' && row.run === runs[0])); ++ assert.deepEqual(launchLog(f).filter(row => row.event === 'lifecycle-end').map(row => row.run), [runs[0]]); ++ await until(() => !readSessions(f.dataRoot).some(row => row.run === runs[0])); ++ assert.ok(readSessions(f.dataRoot).some(row => row.run === runs[1])); ++}); +diff --git a/packages/webui/src/chat-http.mjs b/packages/webui/src/chat-http.mjs +index 7980c1442..63d3ca974 100644 +--- a/packages/webui/src/chat-http.mjs ++++ b/packages/webui/src/chat-http.mjs +@@ -80,7 +80,8 @@ export async function chatHTTP(req, res, { path, search, chat, named, send, prev + (Object.hasOwn(v, 'repo') && (typeof v.repo !== 'string' || !KEY.test(v.repo)))) throw Error(); + action = 'createProject'; args = v; + } else if (path === '/api/chat/threads') { +- if (!exact(v, ['project', 'name']) || !id(v.project) || !name(v.name)) throw Error(); ++ if (!exact(v, ['project', 'name', 'instance'], ['project', 'name']) || !id(v.project) || !name(v.name) || ++ (Object.hasOwn(v, 'instance') && (typeof v.instance !== 'string' || !KEY.test(v.instance)))) throw Error(); + action = 'createThread'; args = v; + } else if (match?.[2] === 'messages') { + if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error(); +diff --git a/packages/webui/src/chat.mjs b/packages/webui/src/chat.mjs +index 1b0a41dce..5a1ed8bf1 100644 +--- a/packages/webui/src/chat.mjs ++++ b/packages/webui/src/chat.mjs +@@ -25,7 +25,7 @@ export function createChat({ call, owner = null }) { + projects: () => channel('project.list', {}), + createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }), + threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }), +- createThread: ({ project, name }) => channel('thread.create', { project, name }), ++ createThread: ({ project, name, instance }) => channel('thread.create', { project, name, ...(instance === undefined ? {} : { instance }) }), + messages: ({ thread, after, limit }) => channel('thread.messages', { thread, ...(after ? { after } : {}), limit }), + send: ({ thread, body }) => channel('thread.send', { thread, body, class: 'REQUEST' }), + open: ({ thread }) => channel('thread.open', { thread }), +diff --git a/packages/webui/src/public/chat.js b/packages/webui/src/public/chat.js +index a6b805c1b..15066bdc6 100644 +--- a/packages/webui/src/public/chat.js ++++ b/packages/webui/src/public/chat.js +@@ -14,7 +14,7 @@ + return states.get(id); + }; + const form = key => { +- if (!forms.has(key)) forms.set(key, { name: '', repo: '', receipt: '', pending: false }); ++ if (!forms.has(key)) forms.set(key, { name: '', repo: '', instance: '', instanceChosen: false, receipt: '', pending: false }); + return forms.get(key); + }; + class ChatError extends Error { +@@ -58,7 +58,11 @@ + function creation(kind, key) { + const f = form(key), project = kind === 'project'; + const repo = project && current.repos.length ? `` : ''; +- return `

New ${kind}

${repo}

${txt(f.receipt)}

`; ++ const choices = current.instances ?? []; ++ const picker = project ? '' : ``; ++ const unavailable = !project && (!!current.instanceError || !choices.some(row => row.instance === f.instance)); ++ const status = !project && current.instanceError ? current.instanceError : f.receipt || (!project && !choices.length ? 'No thread instances are configured.' : ''); ++ return `

New ${kind}

${repo}${picker}

${txt(status)}

`; + } + function threads(rows) { + return rows.length ? `` : '

No threads yet.

'; +@@ -106,7 +110,7 @@ + const f = document.getElementById(keep.field.id); + if (f && !f.disabled) { + f.focus({ preventScroll: true }); +- if (keep.field.start !== null) f.setSelectionRange(keep.field.start, keep.field.end); ++ if (keep.field.start != null && typeof f.setSelectionRange === 'function') f.setSelectionRange(keep.field.start, keep.field.end); + } + } else if (navigated) c.container.querySelector('h1')?.focus({ preventScroll: true }); + } +@@ -126,7 +130,7 @@ + paint(); return; + } + const old = current; +- current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], error: '', refused: false }; ++ current = { container, kind, id, canSend, projects: old?.projects ?? [], threads: old?.threads ?? [], repos: old?.repos ?? [], instances: old?.instances ?? [], instanceError: kind === 'project' ? 'Reading thread instances…' : '', error: '', refused: false }; + if (navigated || !old) container.innerHTML = '

Reading chat…

'; + try { + const s = kind === 'thread' ? state(id) : null; +@@ -147,6 +151,25 @@ + s.receipt = 'Message shown in this conversation.'; s.receiptId = null; + } + } ++ if (kind === 'project') { ++ try { ++ const listed = await request('instances', undefined, signal); ++ if (g !== generation || out()) return; ++ if (!listed.rows.every(row => typeof row?.instance === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/.test(row.instance) && typeof row.name === 'string')) ++ throw new ChatError('invalid-response'); ++ current.instances = listed.rows; current.instanceError = ''; ++ const f = form(id); ++ if (f.instance && !listed.rows.some(row => row.instance === f.instance)) { ++ f.instance = ''; f.instanceChosen = true; ++ f.receipt = 'The selected instance is no longer available. Choose who this thread is with.'; ++ } ++ if (!f.instanceChosen && !f.instance && listed.rows.length === 1) f.instance = listed.rows[0].instance; ++ } catch (err) { ++ if (err.name === 'AbortError' || out() || g !== generation) return; ++ current.instances = []; ++ current.instanceError = "Couldn't read the thread instances; reload to try again"; ++ } ++ } + // Repository choices are optional metadata; an unavailable read must not block unlinked chat. + if (kind === 'list') { + try { +@@ -179,7 +202,11 @@ + if (!current || out()) return; + if (event.target.id === 'chat-body') state(current.id).draft = event.target.value; + const f = event.target.closest('.chat-create'); +- if (f && event.target.dataset.chatField) form(f.dataset.key)[event.target.dataset.chatField] = event.target.value; ++ if (f && event.target.dataset.chatField) { ++ const draft = form(f.dataset.key), field = event.target.dataset.chatField; ++ draft[field] = event.target.value; ++ if (field === 'instance') { draft.instanceChosen = true; draft.receipt = ''; paint(); } ++ } + }); + document.addEventListener('scroll', event => { + if (event.target.matches?.('.chat-log') && current?.kind === 'thread') { +@@ -232,7 +259,8 @@ + void write('send', id, { body: state(id).draft }, `threads/${id}/messages`); + } else if (event.target.classList.contains('chat-create')) { + const key = event.target.dataset.key, f = form(key), project = key === 'projects'; +- const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name }; ++ if (!project && (current.instanceError || !(current.instances ?? []).some(row => row.instance === f.instance))) return; ++ const value = project ? { name: f.name, ...(f.repo ? { repo: f.repo } : {}) } : { project: key, name: f.name, instance: f.instance }; + void write(project ? 'createProject' : 'createThread', key, value, project ? 'projects' : 'threads'); + } + }); +diff --git a/packages/webui/src/serve.mjs b/packages/webui/src/serve.mjs +index cd51a0b6e..56fcdd4e1 100644 +--- a/packages/webui/src/serve.mjs ++++ b/packages/webui/src/serve.mjs +@@ -8,6 +8,7 @@ import { BusReadError, busStatus as coreBusStatus, subjectOk, sendOutcome } from + // Row 176, sage-1111: HTTP-only tester refusals; the row-164 transport pin stays untouched. + const busStatus = code => ['tester-off', 'tester-required'].includes(code) ? 403 : coreBusStatus(code); + import { ReadError, readStatus, fieldTable } from './reads.mjs'; ++import { THREAD_DEFINITIONS } from '../../business/src/vocabulary.mjs'; + import { chatHTTP } from './chat-http.mjs'; + import { processingHTTP } from './processing-http.mjs'; + +@@ -740,6 +741,25 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF + if (req.method !== 'POST') return json(res, 405, { error: 'method not allowed' }); + return busSend(req, res, { send, preview: signer, log }); + } ++ // Row 219: labels only, from this listener's own human or tester bus, after auth above. ++ if (path === '/api/chat/instances') { ++ if (req.method !== 'GET') return json(res, 405, { error: 'method-not-allowed' }); ++ if (search) return json(res, 400, { error: 'invalid-request' }); ++ if (!bus?.instances) return json(res, 503, { error: 'not-configured' }); ++ try { ++ const value = await bus.instances(); ++ if (!value || !value.instances || typeof value.instances !== 'object' || Array.isArray(value.instances)) ++ throw new BusReadError('invalid-response'); ++ const rows = Object.entries(value.instances) ++ .filter(([, entry]) => THREAD_DEFINITIONS.includes(entry?.definition)) ++ .map(([instance, entry]) => ({ instance, name: entry.vars?.['persona.name'] || instance })); ++ if (rows.some(row => typeof row.name !== 'string')) throw new BusReadError('invalid-response'); ++ return json(res, 200, { rows, at: new Date().toISOString() }); ++ } catch (err) { ++ const code = err instanceof BusReadError ? err.code : 'invalid-response'; ++ return json(res, busStatus(code), { error: code }); ++ } ++ } + if (path === '/api/chat' || path.startsWith('/api/chat/')) { + return chatHTTP(req, res, { path, search, chat, named, send, preview: writer, log }); + } +diff --git a/packages/webui/tests/chat-browser.test.mjs b/packages/webui/tests/chat-browser.test.mjs +index f3eeeca96..cc3ec093d 100644 +--- a/packages/webui/tests/chat-browser.test.mjs ++++ b/packages/webui/tests/chat-browser.test.mjs +@@ -12,6 +12,7 @@ const HOSTILE = '\u001b[31m\u202eevil'; + const stub = `(() => { + const real = fetch.bind(window); + window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[]; ++ window.__instances=[{instance:'assistant',name:'assistant'}]; + window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\">',name:'Invalid record'}]; + window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}]; + window.fetch=async (url,init={})=>{ +@@ -33,6 +34,7 @@ const stub = `(() => { + window.__reads.push(path); + if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);} + if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503}); ++ if(path==='/api/chat/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances}); + if(path==='/api/chat/projects')return data({rows:window.__projects}); + if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads}); + if(window.__pages.length)return data(window.__pages.shift()); +@@ -82,7 +84,7 @@ test('chat browser: list/create named projects and threads, hostile labels inert + await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`); + await click('#chat-thread-form button[type=submit]'); + await wait('document.querySelector("#chat-send-form")'); +- assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' }); ++ assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread', instance: 'assistant' }); + assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads'); + assert.equal(await b.evaluate('window.__writes.length'), 2); + }); +@@ -264,3 +266,65 @@ test('chat browser: a late project creation receipt cannot navigate away from an + assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A); + assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha'); + }); ++ ++test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => { ++ const { b, wait, set, click, refresh } = await setup(t); ++ await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`); ++ await wait('document.querySelectorAll("#chat-instance option").length===3'); ++ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), ''); ++ assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true); ++ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true); ++ const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)'); ++ assert.match(labels[1], /\(bean\)/); ++ assert.match(labels[2], /\(chet\)/); ++ assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u); ++ assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false); ++ assert.equal(await b.evaluate('window.injected'), undefined); ++ await set('#chat-thread-name', 'Bean discussion'); ++ await click('#chat-thread-form button'); ++ assert.equal(await b.evaluate('window.__writes.length'), 0); ++ await set('#chat-instance', 'bean'); ++ await refresh(); ++ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean'); ++ assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion'); ++ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false); ++ await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`); ++ await click('#chat-thread-form button'); ++ await wait('window.__writes.length===1'); ++ assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' }); ++}); ++ ++test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => { ++ const { b, wait, set, refresh } = await setup(t); ++ await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`); ++ await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")'); ++ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/); ++ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true); ++ await b.evaluate('window.__instanceFailure=false;window.__instances=[];true'); ++ await refresh(); ++ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/); ++ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true); ++ await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true"); ++ await refresh(); ++ await set('#chat-instance', 'bean'); ++ await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true"); ++ await refresh(); ++ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), ''); ++ assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/); ++ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true); ++ await refresh(); ++ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance'); ++ await set('#chat-instance', 'chet'); ++ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false); ++}); ++ ++test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => { ++ const { b, wait } = await setup(t, '?readonly'); ++ await b.evaluate(`location.hash='#/chat/projects/${P}';true`); ++ await wait('document.querySelectorAll("#chat-instance option").length===2'); ++ assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant'); ++ assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true); ++ assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true); ++ assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/); ++ assert.equal(await b.evaluate('window.__writes.length'), 0); ++}); +diff --git a/packages/webui/tests/chat-instances.test.mjs b/packages/webui/tests/chat-instances.test.mjs +new file mode 100644 +index 000000000..893c38f80 +--- /dev/null ++++ b/packages/webui/tests/chat-instances.test.mjs +@@ -0,0 +1,87 @@ ++import test from 'node:test'; ++import assert from 'node:assert/strict'; ++import { request } from 'node:http'; ++import { mkdtempSync, rmSync } from 'node:fs'; ++import { join } from 'node:path'; ++import { tmpdir } from 'node:os'; ++import { startServer } from '../src/serve.mjs'; ++import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs'; ++import { close } from './fixture.mjs'; ++ ++const path = '/api/chat/instances'; ++const preview = { host: 'chat.example', header: 'x-user', user: 'jason' }; ++const snapshot = { ++ revision: 'a'.repeat(64), plugins: { private: 'do-not-project' }, ++ instances: { ++ assistant: { definition: 'assistant', vars: { model: 'private-model' } }, ++ bean: { definition: 'companion', vars: { 'persona.name': 'Bean', 'persona.sha256': 'private-hash' } }, ++ chet: { definition: 'continuity', vars: { 'persona.name': 'Bean', file: '/private/path' } }, ++ pm: { definition: 'pm', vars: { 'persona.name': 'Hidden PM' } }, ++ }, ++}; ++function get(server, { route = path, method = 'GET', headers = {} } = {}) { ++ const address = server.address(), unix = typeof address === 'string'; ++ return new Promise((resolve, reject) => { ++ const req = request({ ...(unix ? { socketPath: address } : { host: '127.0.0.1', port: address.port }), ++ path: route, method, headers: { host: unix ? new URL(TESTER_ORIGIN).host : preview.host, ...headers } }, res => { ++ let text = ''; ++ res.on('data', chunk => text += chunk); ++ res.on('end', () => resolve({ status: res.statusCode, text, value: JSON.parse(text) })); ++ }); ++ req.on('error', reject); req.end(); ++ }); ++} ++test('C2-09a metadata uses the human listener bus, projects labels only and refuses unauthenticated reads', async t => { ++ let calls = 0; ++ const server = await startServer({ port: 0, preview, bus: { instances: async () => { calls++; return snapshot; } } }); ++ t.after(() => close(server)); ++ const refused = await get(server); ++ assert.equal(refused.status, 401); ++ assert.equal(calls, 0); ++ const answer = await get(server, { headers: { 'x-user': 'jason' } }); ++ assert.equal(answer.status, 200); ++ assert.deepEqual(answer.value.rows, [{ instance: 'assistant', name: 'assistant' }, { instance: 'bean', name: 'Bean' }, { instance: 'chet', name: 'Bean' }]); ++ for (const row of answer.value.rows) assert.deepEqual(Object.keys(row).sort(), ['instance', 'name']); ++ assert.doesNotMatch(answer.text, /private|Hidden PM/); ++ assert.equal(calls, 1); ++ for (const options of [{ route: path + '?extra=1' }, { method: 'POST' }]) { ++ const result = await get(server, { ...options, headers: { 'x-user': 'jason' } }); ++ assert.ok([400, 405].includes(result.status)); ++ } ++ assert.equal(calls, 1, 'invalid metadata requests never call a bus'); ++}); ++test('C2-09a metadata uses the private tester listener own bus and no human transport', async t => { ++ const root = mkdtempSync(join(tmpdir(), 'chat219-')); t.after(() => rmSync(root, { recursive: true, force: true })); ++ let reads = 0; ++ const server = await startTesterSocket({ dataRoot: root, board: 'http://127.0.0.1:9', ++ bus: { instances: async () => { reads++; return { instances: { quill: { definition: 'proofreader', vars: { 'persona.name': 'Quill' } } } }; } } }); ++ t.after(() => close(server)); ++ const answer = await get(server); ++ assert.equal(answer.status, 200); ++ assert.deepEqual(answer.value.rows, [{ instance: 'quill', name: 'Quill' }]); ++ assert.equal(reads, 1); ++}); ++test('C2-09a metadata refuses missing or malformed reads and never echoes untrusted fields', async t => { ++ for (const bus of [null, { instances: async () => ({ instances: [] }) }, { instances: async () => { throw new Error('private-path'); } }]) { ++ const server = await startServer({ port: 0, preview, bus }); t.after(() => close(server)); ++ const answer = await get(server, { headers: { 'x-user': 'jason' } }); ++ assert.ok(answer.status >= 400); ++ assert.doesNotMatch(answer.text, /private-path/); ++ } ++}); ++ ++test('C2-09a metadata stays behind the SSO session check and a GET never refreshes session activity', async t => { ++ let session = null, reads = 0; ++ const touches = []; ++ const sso = { publicHost: 'sso-chat.example', publicOrigin: 'https://sso-chat.example', formTargets: () => [], ++ session: (_req, { touch }) => { touches.push(touch); return session; } }; ++ const server = await startServer({ port: 0, sso, bus: { instances: async () => { reads++; return snapshot; } } }); ++ t.after(() => close(server)); ++ const headers = { host: sso.publicHost }; ++ assert.equal((await get(server, { headers })).status, 401); ++ assert.equal(reads, 0); ++ session = { name: 'jason' }; ++ assert.equal((await get(server, { headers })).status, 200); ++ assert.equal(reads, 1); ++ assert.deepEqual(touches, [false, false]); ++}); +diff --git a/packages/webui/tests/chat.test.mjs b/packages/webui/tests/chat.test.mjs +index 8c11611cf..b47ecf1a7 100644 +--- a/packages/webui/tests/chat.test.mjs ++++ b/packages/webui/tests/chat.test.mjs +@@ -335,3 +335,21 @@ test('the chat seam names the transport it is given: the human one and the teste + assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' }); + assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function'); + }); ++ ++test('C2-09a createThread forwards an explicit instance through the existing channel', async t => { ++ const c = await setup(t); ++ c.state.answer = { id: T, instance: 'bean' }; ++ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } }); ++ assert.equal(answer.status, 200); ++ assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]); ++ assert.equal(answer.data.instance, 'bean'); ++}); ++test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => { ++ const c = await setup(t); ++ for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) { ++ const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } }); ++ assert.equal(answer.status, 400, JSON.stringify(value)); ++ } ++ assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400); ++ assert.deepEqual(c.calls, []); ++}); diff --git a/agents/rocko/work/queue-219/r3/candidate-manifest.sha256 b/agents/rocko/work/queue-219/r3/candidate-manifest.sha256 new file mode 100644 index 000000000..6a46760c2 --- /dev/null +++ b/agents/rocko/work/queue-219/r3/candidate-manifest.sha256 @@ -0,0 +1,14 @@ +142a210882535108159e045c61be288bc8703910a8ee0169137bc1eb80baff64 packages/bus/src/broker.mjs +b0c66c8999b567896923f6c876911dba936c6eca1ccc7aaeada51369fd613594 packages/bus/tests/creative.test.mjs +de4913f50801a11fd172e3f2c2b3cb41ab04e84d32a6650d1c8662b5e227ea3d packages/bus/tests/threads.test.mjs +8f7d6745fffb2a0898ac80c012084ac48964748da6439a8c56facc6529ffb19b packages/bus/tests/user-principal.test.mjs +b74275ce43249bafc97edc87a6a3aa0431c62fe1a28cd9fa265246e6d6da219b packages/cli/src/launcher.mjs +2c3f120dfdc0b52bf8ab38a88e86251be75d4b0d218e23df489dcaf815d6bc15 packages/cli/tests/launcher.test.mjs +7f308fdf601e839194f026beb3fea279eba1e356ce2756aa4a42fa4873b79b30 packages/cli/tests/thread-launch.test.mjs +39c6d207e026debb7db8aade5534503847d0f5c84d560ba28f3cd65490378363 packages/webui/src/chat-http.mjs +77878dd33fd9e0381deb996969389dde21c109eaddf46a8a5c91907ec0dfe24e packages/webui/src/chat.mjs +8e89df5fe8a9da35de41b5968afc6efcd92503e92ef28c6f557bb5371d10d1a4 packages/webui/src/public/chat.js +443c83debe21559de2e4a6bcc07514bce9bebe209df58b8011827fcc1cabd905 packages/webui/src/serve.mjs +d918e5b061ced9db28337a9b02c7042f9ddafaf09f32affce9f51190c0cba93f packages/webui/tests/chat-browser.test.mjs +2bad14535372488de326b82b791b79a61e1a4ab839458535132958ef85b746eb packages/webui/tests/chat-instances.test.mjs +7779d33d5b1dd3fe183722d0f91ff1f71e741e113cf2d5ee407f3d26428fc162 packages/webui/tests/chat.test.mjs diff --git a/agents/rocko/work/queue-219/r3/changed-paths.txt b/agents/rocko/work/queue-219/r3/changed-paths.txt new file mode 100644 index 000000000..689c1cf8e --- /dev/null +++ b/agents/rocko/work/queue-219/r3/changed-paths.txt @@ -0,0 +1,14 @@ +packages/bus/src/broker.mjs +packages/bus/tests/creative.test.mjs +packages/bus/tests/threads.test.mjs +packages/bus/tests/user-principal.test.mjs +packages/cli/src/launcher.mjs +packages/cli/tests/launcher.test.mjs +packages/cli/tests/thread-launch.test.mjs +packages/webui/src/chat-http.mjs +packages/webui/src/chat.mjs +packages/webui/src/public/chat.js +packages/webui/src/serve.mjs +packages/webui/tests/chat-browser.test.mjs +packages/webui/tests/chat-instances.test.mjs +packages/webui/tests/chat.test.mjs diff --git a/agents/rocko/work/queue-219/r3/conflicts.txt b/agents/rocko/work/queue-219/r3/conflicts.txt new file mode 100644 index 000000000..262f06d3a --- /dev/null +++ b/agents/rocko/work/queue-219/r3/conflicts.txt @@ -0,0 +1 @@ +No conflicts. No manual resolutions or product edits. All five commits auto-rebased. diff --git a/agents/rocko/work/queue-219/r3/overlap-readers.json b/agents/rocko/work/queue-219/r3/overlap-readers.json new file mode 100644 index 000000000..768fed6ab --- /dev/null +++ b/agents/rocko/work/queue-219/r3/overlap-readers.json @@ -0,0 +1,131 @@ +{ + "method": "resolve literal relative import/URL/file paths in packages/*/tests/**/*.mjs; reverse test-helper dependency closure; no tests run", + "readers": { + "packages/webui/src/chat-http.mjs": { + "directModules": [ + "packages/webui/tests/chat.test.mjs" + ], + "wholeTestReaders": [ + "packages/webui/tests/chat.test.mjs" + ] + }, + "packages/webui/src/chat.mjs": { + "directModules": [ + "packages/webui/tests/channel-chat.test.mjs", + "packages/webui/tests/chat.test.mjs", + "packages/webui/tests/tester-fixture.mjs" + ], + "wholeTestReaders": [ + "packages/webui/tests/channel-chat.test.mjs", + "packages/webui/tests/chat.test.mjs", + "packages/webui/tests/proof-cli.test.mjs", + "packages/webui/tests/tester-browser.test.mjs", + "packages/webui/tests/tester-cli.test.mjs", + "packages/webui/tests/tester-listener.test.mjs" + ] + }, + "packages/webui/src/serve.mjs": { + "directModules": [ + "packages/webui/tests/agents-browser.test.mjs", + "packages/webui/tests/agents.test.mjs", + "packages/webui/tests/assistant.test.mjs", + "packages/webui/tests/board-held.test.mjs", + "packages/webui/tests/browser-edge.test.mjs", + "packages/webui/tests/bus-browser.test.mjs", + "packages/webui/tests/bus.test.mjs", + "packages/webui/tests/channel-chat.test.mjs", + "packages/webui/tests/chat-browser.test.mjs", + "packages/webui/tests/chat-instances.test.mjs", + "packages/webui/tests/chat.test.mjs", + "packages/webui/tests/discord.test.mjs", + "packages/webui/tests/docs-browser.test.mjs", + "packages/webui/tests/editor-browser.test.mjs", + "packages/webui/tests/fixture.mjs", + "packages/webui/tests/granted-user.test.mjs", + "packages/webui/tests/history-fixture.mjs", + "packages/webui/tests/instances-browser.test.mjs", + "packages/webui/tests/instances.test.mjs", + "packages/webui/tests/limits-browser.test.mjs", + "packages/webui/tests/limits-route.test.mjs", + "packages/webui/tests/messages.test.mjs", + "packages/webui/tests/preview.test.mjs", + "packages/webui/tests/processing-browser.test.mjs", + "packages/webui/tests/processing.test.mjs", + "packages/webui/tests/project-file.test.mjs", + "packages/webui/tests/proof.test.mjs", + "packages/webui/tests/reads.test.mjs", + "packages/webui/tests/relaunch.test.mjs", + "packages/webui/tests/return-flow.test.mjs", + "packages/webui/tests/routes-browser.test.mjs", + "packages/webui/tests/routes.test.mjs", + "packages/webui/tests/send-browser.test.mjs", + "packages/webui/tests/send.test.mjs", + "packages/webui/tests/serve.test.mjs", + "packages/webui/tests/settings-form.test.mjs", + "packages/webui/tests/settings-write.test.mjs", + "packages/webui/tests/shell.test.mjs", + "packages/webui/tests/sso-browser.test.mjs", + "packages/webui/tests/sso.test.mjs", + "packages/webui/tests/tester-listener.test.mjs", + "packages/webui/tests/thread-claims-browser.test.mjs", + "packages/webui/tests/thread-claims.test.mjs", + "packages/webui/tests/usage-browser.test.mjs", + "packages/webui/tests/usage.test.mjs", + "packages/webui/tests/views.test.mjs" + ], + "wholeTestReaders": [ + "packages/webui/tests/agents-browser.test.mjs", + "packages/webui/tests/agents.test.mjs", + "packages/webui/tests/assistant.test.mjs", + "packages/webui/tests/board-held.test.mjs", + "packages/webui/tests/browser-edge.test.mjs", + "packages/webui/tests/browser.test.mjs", + "packages/webui/tests/bus-browser.test.mjs", + "packages/webui/tests/bus.test.mjs", + "packages/webui/tests/channel-chat.test.mjs", + "packages/webui/tests/chat-browser.test.mjs", + "packages/webui/tests/chat-instances.test.mjs", + "packages/webui/tests/chat.test.mjs", + "packages/webui/tests/conversation.test.mjs", + "packages/webui/tests/discord.test.mjs", + "packages/webui/tests/docs-browser.test.mjs", + "packages/webui/tests/editor-browser.test.mjs", + "packages/webui/tests/editor.test.mjs", + "packages/webui/tests/granted-user.test.mjs", + "packages/webui/tests/history-return-flow.test.mjs", + "packages/webui/tests/instances-browser.test.mjs", + "packages/webui/tests/instances.test.mjs", + "packages/webui/tests/limits-browser.test.mjs", + "packages/webui/tests/limits-route.test.mjs", + "packages/webui/tests/messages.test.mjs", + "packages/webui/tests/preview.test.mjs", + "packages/webui/tests/processing-browser.test.mjs", + "packages/webui/tests/processing.test.mjs", + "packages/webui/tests/project-file.test.mjs", + "packages/webui/tests/proof-cli.test.mjs", + "packages/webui/tests/proof.test.mjs", + "packages/webui/tests/reads.test.mjs", + "packages/webui/tests/relaunch.test.mjs", + "packages/webui/tests/return-flow.test.mjs", + "packages/webui/tests/routes-browser.test.mjs", + "packages/webui/tests/routes.test.mjs", + "packages/webui/tests/send-browser.test.mjs", + "packages/webui/tests/send.test.mjs", + "packages/webui/tests/serve.test.mjs", + "packages/webui/tests/settings-form.test.mjs", + "packages/webui/tests/settings-write.test.mjs", + "packages/webui/tests/shell.test.mjs", + "packages/webui/tests/sso-browser.test.mjs", + "packages/webui/tests/sso.test.mjs", + "packages/webui/tests/tester-browser.test.mjs", + "packages/webui/tests/tester-cli.test.mjs", + "packages/webui/tests/tester-listener.test.mjs", + "packages/webui/tests/thread-claims-browser.test.mjs", + "packages/webui/tests/thread-claims.test.mjs", + "packages/webui/tests/usage-browser.test.mjs", + "packages/webui/tests/usage.test.mjs", + "packages/webui/tests/views.test.mjs" + ] + } + } +} diff --git a/agents/rocko/work/queue-219/r3/path-patch-ids.json b/agents/rocko/work/queue-219/r3/path-patch-ids.json new file mode 100644 index 000000000..0cabf51e3 --- /dev/null +++ b/agents/rocko/work/queue-219/r3/path-patch-ids.json @@ -0,0 +1,72 @@ +[ + { + "path": "packages/bus/src/broker.mjs", + "old": "34f42b312c3d4ae9a975c5d27214414606cb9154", + "new": "34f42b312c3d4ae9a975c5d27214414606cb9154" + }, + { + "path": "packages/bus/tests/creative.test.mjs", + "old": "69deed0c277da57ef36d09097f4164180e744960", + "new": "69deed0c277da57ef36d09097f4164180e744960" + }, + { + "path": "packages/bus/tests/threads.test.mjs", + "old": "83597aea2f53a587b338b32a8e25e9ddac427ca1", + "new": "83597aea2f53a587b338b32a8e25e9ddac427ca1" + }, + { + "path": "packages/bus/tests/user-principal.test.mjs", + "old": "2cb1ee6201051cb338fb67dd46700ec72f7b76a3", + "new": "2cb1ee6201051cb338fb67dd46700ec72f7b76a3" + }, + { + "path": "packages/cli/src/launcher.mjs", + "old": "ef04082e6e0ce574ca3af546871838ac34a5e5ee", + "new": "ef04082e6e0ce574ca3af546871838ac34a5e5ee" + }, + { + "path": "packages/cli/tests/launcher.test.mjs", + "old": "4449e824038428b8dd4e5c299cc126ab1e842b58", + "new": "4449e824038428b8dd4e5c299cc126ab1e842b58" + }, + { + "path": "packages/cli/tests/thread-launch.test.mjs", + "old": "0fef61471873c35652605168dedd7dcd22cb4944", + "new": "0fef61471873c35652605168dedd7dcd22cb4944" + }, + { + "path": "packages/webui/src/chat-http.mjs", + "old": "ccf9e56879a90656c7061ce0d614bfaaf04987ba", + "new": "ccf9e56879a90656c7061ce0d614bfaaf04987ba" + }, + { + "path": "packages/webui/src/chat.mjs", + "old": "bf9a23689c99dea25cbfbac33d84f7074aaf40e1", + "new": "bf9a23689c99dea25cbfbac33d84f7074aaf40e1" + }, + { + "path": "packages/webui/src/public/chat.js", + "old": "f114af96936122f620ebf0a561e3a41a0ff6c955", + "new": "f114af96936122f620ebf0a561e3a41a0ff6c955" + }, + { + "path": "packages/webui/src/serve.mjs", + "old": "c3ec7940dfbdea4a5ca6865898581b2856c4b11a", + "new": "c3ec7940dfbdea4a5ca6865898581b2856c4b11a" + }, + { + "path": "packages/webui/tests/chat-browser.test.mjs", + "old": "8cb7a6270bc8c0f2191caae713842609df0deb92", + "new": "8cb7a6270bc8c0f2191caae713842609df0deb92" + }, + { + "path": "packages/webui/tests/chat-instances.test.mjs", + "old": "8018efd8ad48108293e41b77fc6c786500ef2471", + "new": "8018efd8ad48108293e41b77fc6c786500ef2471" + }, + { + "path": "packages/webui/tests/chat.test.mjs", + "old": "acb49d2d659373b56409cf144f56fe6c29ffdac5", + "new": "acb49d2d659373b56409cf144f56fe6c29ffdac5" + } +] diff --git a/agents/rocko/work/queue-219/r3/range-diff.txt b/agents/rocko/work/queue-219/r3/range-diff.txt new file mode 100644 index 000000000..40bf1c3ad --- /dev/null +++ b/agents/rocko/work/queue-219/r3/range-diff.txt @@ -0,0 +1,14 @@ +1: d5bf46ecf ! 1: 268835a99 wip: publish unverified row 219 inspection candidate + @@ packages/webui/src/chat-http.mjs: export async function chatHTTP(req, res, { pat + if (!exact(v, ['body']) || typeof v.body !== 'string' || !v.body.trim() || v.body.length > 32768) throw Error(); + + ## packages/webui/src/chat.mjs ## + -@@ packages/webui/src/chat.mjs: export function createChat({ call }) { + +@@ packages/webui/src/chat.mjs: export function createChat({ call, owner = null }) { + projects: () => channel('project.list', {}), + createProject: ({ name, repo }) => channel('project.create', { name, ...(repo === undefined ? {} : { repo }) }), + threads: ({ project } = {}) => channel('thread.list', project === undefined ? {} : { project }), +2: c7bf3622d = 2: 994860e70 docs: rebase row 219 gate packet and record run bounds +3: b97a3ff97 = 3: 0e040cf94 test: give row 219 persona fixture its explicit session +4: 7cbd839d0 = 4: 30cf10301 test: pin the compaction audit before the persona refusal +5: 5f2ef92e5 = 5: 28bf34f27 test: assert creative history in its native session store diff --git a/agents/rocko/work/queue-219/r3/rebased-source-tip.txt b/agents/rocko/work/queue-219/r3/rebased-source-tip.txt new file mode 100644 index 000000000..f48639cd7 --- /dev/null +++ b/agents/rocko/work/queue-219/r3/rebased-source-tip.txt @@ -0,0 +1 @@ +28bf34f272ec8e0ad0f7155556d2c92cdb3dd5be