fuli-context 0.8.3 → 0.9.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -6
- package/README.zh-CN.md +92 -6
- package/dist/web/assets/{AboutPage-HLhChD_Q.js → AboutPage-u2Bv6cO-.js} +1 -1
- package/dist/web/assets/{AgentHand-BaLSbn08.js → AgentHand-CsMcNXUm.js} +1 -1
- package/dist/web/assets/{AgentName-0PIU7MOX.js → AgentName-BboCA-Fd.js} +1 -1
- package/dist/web/assets/{AgentProfilePage-9cSsZHnk.js → AgentProfilePage-Dyux6qE3.js} +1 -1
- package/dist/web/assets/AgentsDirectoryPage-BEJdvmX7.js +1 -0
- package/dist/web/assets/{ConnectionsPage-D0wPYbwd.js → ConnectionsPage-Dw_hK_EM.js} +1 -1
- package/dist/web/assets/{EmployeeRecruitDialog-BrPH3e50.js → EmployeeRecruitDialog-DP_ktv3w.js} +1 -1
- package/dist/web/assets/{EmployeeWorkbenchPage-u-jw38PW.js → EmployeeWorkbenchPage-CAt8lVnr.js} +1 -1
- package/dist/web/assets/{GrowthLoading-DFOy-mEl.js → GrowthLoading-DIwqGFSc.js} +1 -1
- package/dist/web/assets/{KnowledgeEditDialog-DBsjlkDr.js → KnowledgeEditDialog-Degd9AGD.js} +1 -1
- package/dist/web/assets/{KnowledgeInspector-C79hbuOo.js → KnowledgeInspector-yms1ysZ4.js} +1 -1
- package/dist/web/assets/{KnowledgeOrganizerPage-Cz1BnAtz.js → KnowledgeOrganizerPage-C64wNn8a.js} +1 -1
- package/dist/web/assets/KnowledgePage-BTFEXzgi.js +1 -0
- package/dist/web/assets/{KnowledgeWorkspace-B2vj12QG.js → KnowledgeWorkspace-BsF1X4Aq.js} +1 -1
- package/dist/web/assets/{NotFoundPage-D340EQJP.js → NotFoundPage-DfaNTiD1.js} +1 -1
- package/dist/web/assets/{OverviewPage-BKiYudrC.js → OverviewPage-VXMfcWFI.js} +1 -1
- package/dist/web/assets/{PersonalProfilePage-BIGTcem2.js → PersonalProfilePage-C1LBhcYu.js} +1 -1
- package/dist/web/assets/PersonalProjectsPage-B5lV4ZJW.js +1 -0
- package/dist/web/assets/ProjectAgentFirstTask-6afJ8HUe.css +1 -0
- package/dist/web/assets/ProjectAgentFirstTask-DF-IvEqp.js +1 -0
- package/dist/web/assets/ProjectAgentsPage-CgL92gng.js +13 -0
- package/dist/web/assets/ProjectAgentsPage-CoJ4qQlA.css +1 -0
- package/dist/web/assets/{ProjectScopePicker-8hrgvmg-.js → ProjectScopePicker-CZUMdyYz.js} +1 -1
- package/dist/web/assets/{PublicProjectsPage-b5zx0dFS.js → PublicProjectsPage-e4CpxHk9.js} +1 -1
- package/dist/web/assets/{ReviewPage-BLdWYDmj.js → ReviewPage-DugEaFvI.js} +1 -1
- package/dist/web/assets/RoundtablesPage-642YtZBC.js +2 -0
- package/dist/web/assets/RoundtablesPage-DCbIBnBq.css +1 -0
- package/dist/web/assets/{SearchableSelect-vaoSDq1g.js → SearchableSelect-Do6X3fXi.js} +1 -1
- package/dist/web/assets/{SettingsPage-Dxjrkq1g.js → SettingsPage-wqYxDPyu.js} +1 -1
- package/dist/web/assets/{SharedJefaReportPage-YG2xwQQy.js → SharedJefaReportPage-CNQDezJr.js} +1 -1
- package/dist/web/assets/{WritingTastePage-CPPduPb1.js → WritingTastePage-CAUvbDlL.js} +1 -1
- package/dist/web/assets/{agent-profile.css_vue_type_style_index_0_src_true_lang-LTJ1ptv9.js → agent-profile.css_vue_type_style_index_0_src_true_lang-DHOcWSe_.js} +1 -1
- package/dist/web/assets/{catalog-B9tlJAp9.js → catalog-JmKyJnKL.js} +1 -1
- package/dist/web/assets/{client-InXscj8J.js → client-DTQ_tUr2.js} +1 -1
- package/dist/web/assets/{console-hUns2aD-.js → console-CrhIK67Y.js} +1 -1
- package/dist/web/assets/{conversation-launcher-settings-DEtJ6pZg.js → conversation-launcher-settings-fUWCt3XG.js} +1 -1
- package/dist/web/assets/copy-CX6pyzAG.js +1 -0
- package/dist/web/assets/i18n-CIIxzhPZ.js +10 -0
- package/dist/web/assets/{identity-Wt2Xio_B.js → identity-CdFITIOu.js} +1 -1
- package/dist/web/assets/{index-CWMqDq8g.css → index-CYZi7E1e.css} +1 -1
- package/dist/web/assets/index-DMoxVzDU.js +2 -0
- package/dist/web/assets/{model-C1GwBvd9.js → model-CJsrDUNb.js} +1 -1
- package/dist/web/assets/{personal-profile-graph-BdV_3I1u.js → personal-profile-graph-CQLOait6.js} +1 -1
- package/dist/web/assets/{shared-report-tRQV1iJl.js → shared-report-Cll2Yj8W.js} +1 -1
- package/dist/web/assets/{task-evidence-BUu7yYIn.js → task-evidence-DTiPCs7r.js} +1 -1
- package/dist/web/assets/{useApi-CROJJdhE-QfNm4ADP.js → useApi-CROJJdhE-fcdVGInY.js} +1 -1
- package/dist/web/assets/{useModalDialog-BopF-KHN.js → useModalDialog-B-45idX9.js} +1 -1
- package/dist/web/assets/{vue-router-COCNAJJn.js → vue-router-BUX6njIv.js} +1 -1
- package/dist/web/index.html +12 -11
- package/docs/employee-agents.md +20 -1
- package/docs/roundtable-beta-testing.md +102 -0
- package/docs/roundtable-beta-validation.md +125 -0
- package/docs/roundtable-research.md +142 -0
- package/graph-provider/fuli_graph/app.py +5 -0
- package/graph-provider/fuli_graph/models.py +1 -0
- package/graph-provider/fuli_graph/project_agent_task_models.py +1 -0
- package/graph-provider/fuli_graph/store_project_agent_context.py +4 -0
- package/graph-provider/fuli_graph/store_project_agent_task_reads.py +23 -8
- package/graph-provider/fuli_graph/store_project_agent_tasks.py +1 -0
- package/graph-provider/fuli_graph/store_records.py +1 -0
- package/graph-provider/fuli_graph/store_task_contexts.py +2 -1
- package/graph-provider/fuli_graph/task_context_adoption.py +9 -4
- package/graph-provider/fuli_graph/task_context_temporary_owner.py +25 -0
- package/graph-provider/fuli_graph/temporary_project_scope.py +88 -0
- package/npm-shrinkwrap.json +131 -79
- package/package.json +7 -3
- package/skills/capturing-session-knowledge/SKILL.md +20 -5
- package/src/agent-tools/graph-definitions.js +1 -1
- package/src/agent-tools/interface-catalog.js +5 -0
- package/src/agent-tools/project-agent-definitions.js +2 -2
- package/src/agent-tools.js +9 -2
- package/src/agents/a2a/roundtable-participant.js +374 -0
- package/src/agents/claude-code/roundtable-participant.js +24 -0
- package/src/agents/codex/roundtable-config.js +29 -0
- package/src/agents/codex/roundtable-participant.js +34 -0
- package/src/agents/grok/roundtable-participant.js +185 -0
- package/src/agents/pi/roundtable-participant.js +193 -0
- package/src/agents/pi/roundtable-result-format.js +183 -0
- package/src/cli/command-registry.js +2 -0
- package/src/cli/roundtable-command.js +59 -0
- package/src/cli.js +5 -0
- package/src/graphiti/agent-knowledge-workflows.js +14 -5
- package/src/graphiti/federated-application.js +3 -1
- package/src/graphiti/project-agent-mapping.js +1 -0
- package/src/graphiti/project-agent-workflows.js +28 -11
- package/src/graphiti/project-path-context.js +1 -0
- package/src/graphiti/provider-client.js +3 -0
- package/src/graphiti/temporary-project-scope.js +46 -0
- package/src/http/api-router.js +2 -0
- package/src/http/error-mapping.js +2 -0
- package/src/mcp/provider-task-context-registry.js +10 -0
- package/src/mcp/session-id.js +1 -0
- package/src/mcp/tool-annotations.js +2 -0
- package/src/roundtables/application.js +69 -0
- package/src/roundtables/domain.js +181 -0
- package/src/roundtables/http-router.js +56 -0
- package/src/roundtables/mcp-server.js +48 -0
- package/src/roundtables/participant-registry.js +12 -0
- package/src/roundtables/process.js +70 -0
- package/src/roundtables/result-contract.js +47 -0
- package/src/roundtables/server.js +52 -0
- package/src/roundtables/service.js +396 -0
- package/src/roundtables/store.js +59 -0
- package/src/roundtables/tool-contract.js +41 -0
- package/src/roundtables/turn-prompt.js +21 -0
- package/src/roundtables/worker.js +85 -0
- package/dist/web/assets/AgentsDirectoryPage-7G02Xm6C.js +0 -1
- package/dist/web/assets/KnowledgePage-JyfFFUrg.js +0 -1
- package/dist/web/assets/PersonalProjectsPage-CzjDCIi0.js +0 -1
- package/dist/web/assets/ProjectAgentsPage-C5F2WgwJ.js +0 -13
- package/dist/web/assets/ProjectAgentsPage-C89ZEDgi.css +0 -1
- package/dist/web/assets/i18n-75woNt31.js +0 -9
- package/dist/web/assets/index-pleRuLpw.js +0 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Own exactly one child/process group; never search for or kill unrelated clients.
|
|
4
|
+
export function runParticipantProcess(command, args, { cwd, env = process.env, signal,
|
|
5
|
+
input, timeoutMs = 300_000, maxOutputBytes = 2_000_000 } = {}) {
|
|
6
|
+
signal?.throwIfAborted();
|
|
7
|
+
return new Promise((resolve, reject) => {
|
|
8
|
+
const child = spawn(command, args, { cwd, env, shell: false, windowsHide: true,
|
|
9
|
+
detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
10
|
+
let output = '', outputBytes = 0, failure, settled = false, hardTimer;
|
|
11
|
+
const kill = () => {
|
|
12
|
+
if (!child.pid) return;
|
|
13
|
+
if (process.platform === 'win32') {
|
|
14
|
+
const killer = spawn('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore', shell: false });
|
|
15
|
+
killer.on('error', () => child.kill()); killer.unref();
|
|
16
|
+
} else { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }
|
|
17
|
+
};
|
|
18
|
+
const finish = (error, code) => {
|
|
19
|
+
if (settled) return;
|
|
20
|
+
settled = true;
|
|
21
|
+
clearTimeout(timer); clearTimeout(hardTimer); signal?.removeEventListener('abort', abort);
|
|
22
|
+
kill(); child.stdin.destroy(); child.stdout.destroy(); child.stderr.destroy(); child.unref();
|
|
23
|
+
if (error || failure) reject(error ?? failure); else resolve({ code, stdout: output });
|
|
24
|
+
};
|
|
25
|
+
const stop = (code) => {
|
|
26
|
+
if (failure || settled) return;
|
|
27
|
+
failure = Object.assign(new Error(code), { code }); kill();
|
|
28
|
+
hardTimer = setTimeout(() => finish(failure), 2000);
|
|
29
|
+
};
|
|
30
|
+
const abort = () => stop('cancelled');
|
|
31
|
+
const timer = setTimeout(() => stop('turn_timeout'), timeoutMs);
|
|
32
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
33
|
+
child.stdout.on('data', (chunk) => {
|
|
34
|
+
outputBytes += chunk.length;
|
|
35
|
+
if (outputBytes > maxOutputBytes) { stop('output_too_large'); return; }
|
|
36
|
+
output += chunk.toString('utf8');
|
|
37
|
+
});
|
|
38
|
+
// Drain upstream stderr without echoing keys, prompts, settings or credentials.
|
|
39
|
+
child.stderr.on('data', () => {});
|
|
40
|
+
child.on('error', () => finish(Object.assign(new Error('Participant process unavailable'), { code: 'runtime_unavailable' })));
|
|
41
|
+
child.on('close', (code) => finish(null, code));
|
|
42
|
+
child.stdin.on('error', () => {});
|
|
43
|
+
child.stdin.end(input);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseParticipantEvents(application, result) {
|
|
48
|
+
const events = result.stdout.split(/\r?\n/).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
49
|
+
let body = '', sessionId = null, usage = null, failed = result.code !== 0;
|
|
50
|
+
let codexCompleted = false, pendingCodexError = false;
|
|
51
|
+
for (const event of events) {
|
|
52
|
+
sessionId ??= event.thread_id ?? event.session_id ?? null;
|
|
53
|
+
if (application === 'codex') {
|
|
54
|
+
if (event.type === 'item.completed' && event.item?.type === 'agent_message') body = event.item.text ?? '';
|
|
55
|
+
if (event.type === 'turn.completed') {
|
|
56
|
+
usage = event.usage ?? null; codexCompleted = true; pendingCodexError = false;
|
|
57
|
+
}
|
|
58
|
+
if (event.type === 'error') pendingCodexError = true;
|
|
59
|
+
if (event.type === 'turn.failed') failed = true;
|
|
60
|
+
} else if (event.type === 'result') {
|
|
61
|
+
body = event.structured_output ? JSON.stringify(event.structured_output) : event.result ?? ''; usage = event.usage ?? null; failed ||= Boolean(event.is_error);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const actual = { sourceApplication: application, sessionId, model: null,
|
|
65
|
+
usage: usage ? { source: 'executor', ...usage } : null, evidenceLevel: 'real_cli' };
|
|
66
|
+
if (application === 'codex' && (!codexCompleted || pendingCodexError)) failed = true;
|
|
67
|
+
if (failed) throw Object.assign(new Error('Participant execution failed; inspect client authorization locally'), { code: 'runtime_failed', actual });
|
|
68
|
+
if (!body || Buffer.byteLength(body) > 16_384) throw Object.assign(new Error('Participant final response missing or too large'), { code: 'response_invalid', actual });
|
|
69
|
+
return { body, actual };
|
|
70
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export const PARTICIPANT_RESULT_SCHEMA = {
|
|
2
|
+
type: 'object', additionalProperties: false,
|
|
3
|
+
required: ['body', 'status', 'artifacts', 'verification', 'dissent'],
|
|
4
|
+
properties: {
|
|
5
|
+
body: { type: 'string', minLength: 1, maxLength: 12000 },
|
|
6
|
+
status: { type: 'string', enum: ['completed', 'failed', 'blocked'] },
|
|
7
|
+
artifacts: { type: 'array', maxItems: 20, items: { type: 'object', additionalProperties: false,
|
|
8
|
+
required: ['id', 'uri'], properties: { id: { type: 'string', minLength: 1, maxLength: 1024 }, uri: { type: 'string', minLength: 1, maxLength: 2048 } } } },
|
|
9
|
+
verification: { type: 'object', additionalProperties: false, required: ['passed', 'summary'],
|
|
10
|
+
properties: { passed: { type: ['boolean', 'null'] }, summary: { type: 'string', maxLength: 2048 } } },
|
|
11
|
+
dissent: { type: 'array', maxItems: 16, items: { type: 'string', minLength: 1, maxLength: 1024 } }
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function parseRoundtableParticipantResult(result, phase) {
|
|
16
|
+
let envelope;
|
|
17
|
+
try { envelope = JSON.parse(result.body); } catch {
|
|
18
|
+
throw Object.assign(new Error('Participant must return the requested JSON result envelope'), { code: 'result_contract_invalid', actual: result.actual });
|
|
19
|
+
}
|
|
20
|
+
if (!envelope || typeof envelope.body !== 'string' || !envelope.body.trim() || Buffer.byteLength(envelope.body) > 16_384 ||
|
|
21
|
+
!['completed', 'failed', 'blocked'].includes(envelope.status) || !Array.isArray(envelope.artifacts) || envelope.artifacts.length > 20 ||
|
|
22
|
+
envelope.artifacts.some(artifact => !artifact || !boundedText(artifact.id, 1024) || !boundedText(artifact.uri, 2048)) ||
|
|
23
|
+
!Array.isArray(envelope.dissent) || envelope.dissent.length > 16 || envelope.dissent.some(item => !boundedText(item, 2048)) ||
|
|
24
|
+
Buffer.byteLength(JSON.stringify(envelope.dissent), 'utf8') > 8192 ||
|
|
25
|
+
!envelope.verification || ![true, false, null].includes(envelope.verification.passed) ||
|
|
26
|
+
typeof envelope.verification.summary !== 'string' || Buffer.byteLength(envelope.verification.summary, 'utf8') > 2048) {
|
|
27
|
+
throw Object.assign(new Error('Invalid participant result structure'), { code: 'result_contract_invalid', actual: result.actual });
|
|
28
|
+
}
|
|
29
|
+
if (envelope.status === 'completed' && phase === 'review' && typeof envelope.verification.passed !== 'boolean') {
|
|
30
|
+
throw Object.assign(new Error('Review requires an explicit verification verdict'), { code: 'review_verdict_required', actual: result.actual });
|
|
31
|
+
}
|
|
32
|
+
if (envelope.status === 'completed' && phase === 'implementation' && envelope.artifacts.length === 0) {
|
|
33
|
+
throw Object.assign(new Error('Implementation requires actual artifact references'), { code: 'artifact_required', actual: result.actual });
|
|
34
|
+
}
|
|
35
|
+
const artifacts = [...envelope.artifacts, ...(Array.isArray(result.artifacts) ? result.artifacts : [])];
|
|
36
|
+
if (artifacts.length > 20 || artifacts.some(artifact => typeof artifact === 'string' ? !boundedText(artifact, 1024) :
|
|
37
|
+
!artifact || !boundedText(artifact.id ?? artifact.artifactId, 1024) ||
|
|
38
|
+
['name', 'uri', 'mimeType', 'kind'].some(key => artifact[key] !== undefined && !boundedText(artifact[key], 2048)))) {
|
|
39
|
+
throw Object.assign(new Error('Invalid or excessive adapter artifact references'), { code: 'result_contract_invalid', actual: result.actual });
|
|
40
|
+
}
|
|
41
|
+
return { ...result, body: envelope.body, status: envelope.status,
|
|
42
|
+
artifacts, verification: envelope.verification, dissent: envelope.dissent };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function boundedText(value, bytes) {
|
|
46
|
+
return typeof value === 'string' && Boolean(value.trim()) && Buffer.byteLength(value, 'utf8') <= bytes;
|
|
47
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createRoundtableService } from './service.js';
|
|
5
|
+
import { handleRoundtableApiRequest, handleRoundtablePeerRequest } from './http-router.js';
|
|
6
|
+
import { handleRoundtableMcpRequest } from './mcp-server.js';
|
|
7
|
+
import { serveStatic } from '../http/static-handler.js';
|
|
8
|
+
import { sendJson } from '../http/response.js';
|
|
9
|
+
import { localServerAuthority, rejectRequestOutsidePolicy } from '../http/request-policy.js';
|
|
10
|
+
import { mapHttpError } from '../http/error-mapping.js';
|
|
11
|
+
|
|
12
|
+
export async function createRoundtableServer({ dataDir, service: configuredService,
|
|
13
|
+
port = 3738, host = '127.0.0.1', allowedHosts = [] } = {}) {
|
|
14
|
+
if (!configuredService && !dataDir) throw new TypeError('Roundtable data directory required');
|
|
15
|
+
if (dataDir) mkdirSync(dataDir, { recursive: true });
|
|
16
|
+
const service = configuredService ?? createRoundtableService({ databasePath: join(dataDir, 'roundtables.sqlite') });
|
|
17
|
+
let authority;
|
|
18
|
+
const server = createServer((request, response) => {
|
|
19
|
+
void (async () => {
|
|
20
|
+
const url = new URL(request.url, 'http://127.0.0.1');
|
|
21
|
+
if (url.pathname.startsWith('/roundtable-peer/')) {
|
|
22
|
+
// A public reverse proxy must preserve an explicitly allowed Host.
|
|
23
|
+
if (![authority, ...allowedHosts].includes(request.headers.host) || request.headers.origin) {
|
|
24
|
+
sendJson(response, 403, { error: 'Peer authority forbidden' }); return;
|
|
25
|
+
}
|
|
26
|
+
if (await handleRoundtableMcpRequest({ request, response, url, service }) ||
|
|
27
|
+
await handleRoundtablePeerRequest({ request, response, url, service })) return;
|
|
28
|
+
sendJson(response, 404, { error: 'Peer route not found' }); return;
|
|
29
|
+
}
|
|
30
|
+
const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.socket.remoteAddress);
|
|
31
|
+
if (!local || rejectRequestOutsidePolicy({ request, response, authority })) {
|
|
32
|
+
if (!response.writableEnded) sendJson(response, 403, { error: 'Owner console is local only' }); return;
|
|
33
|
+
}
|
|
34
|
+
if (url.pathname === '/api/health') { sendJson(response, 200, { status: 'ready', service: 'fuli-roundtable' }); return; }
|
|
35
|
+
if (await handleRoundtableApiRequest({ request, response, url, service })) return;
|
|
36
|
+
if (url.pathname.startsWith('/api/')) { sendJson(response, 404, { error: 'Only Roundtable APIs are available on this coordinator' }); return; }
|
|
37
|
+
serveStatic(url.pathname, response);
|
|
38
|
+
})().catch((error) => {
|
|
39
|
+
if (response.headersSent || response.destroyed) return;
|
|
40
|
+
const mapped = error.status ? { status: error.status, body: { error: error.message, code: error.code } } : mapHttpError(error);
|
|
41
|
+
sendJson(response, mapped.status, mapped.body);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
try {
|
|
45
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
|
|
46
|
+
authority = localServerAuthority(server.address());
|
|
47
|
+
} catch (error) { if (!configuredService) service.close(); throw error; }
|
|
48
|
+
let closing;
|
|
49
|
+
return { server, service, url: `http://${authority}`,
|
|
50
|
+
close: () => closing ??= new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
|
|
51
|
+
.finally(() => { if (!configuredService) service.close(); }) };
|
|
52
|
+
}
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { createRoundtableStore } from './store.js';
|
|
3
|
+
import { assertMutable, budgetReason, event, fail, finishTurn, newRoom, normalizeLimits, publicRoom, publicSeat, publicTurn, scheduleTurn, TERMINAL_STATUSES, text } from './domain.js';
|
|
4
|
+
|
|
5
|
+
const PROCESS_BOOT_ID = randomUUID();
|
|
6
|
+
const closedIssuers = new Set();
|
|
7
|
+
const hash = value => createHash('sha256').update(value).digest('hex');
|
|
8
|
+
function canonical(value) {
|
|
9
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
10
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().filter(key => value[key] !== undefined).map(key => [key, canonical(value[key])]));
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
const inputHash = value => hash(JSON.stringify(canonical(value)));
|
|
14
|
+
function boundedJson(value, name, max = 8192) {
|
|
15
|
+
if (value === undefined || value === null) return null;
|
|
16
|
+
const serialized = JSON.stringify(value);
|
|
17
|
+
if (!serialized || Buffer.byteLength(serialized) > max) fail('invalid_input', `${name} exceeds its size bound`);
|
|
18
|
+
return JSON.parse(serialized);
|
|
19
|
+
}
|
|
20
|
+
function artifacts(value = []) {
|
|
21
|
+
if (!Array.isArray(value) || value.length > 20) fail('invalid_artifacts', 'At most 20 artifact references are allowed');
|
|
22
|
+
return value.map(item => {
|
|
23
|
+
if (typeof item === 'string') return text(item, 'artifact reference', 1024);
|
|
24
|
+
if (!item || typeof item !== 'object') fail('invalid_artifacts', 'Artifact references need an ID');
|
|
25
|
+
const result = { id: text(item.id, 'artifact.id', 1024) };
|
|
26
|
+
for (const key of ['name', 'uri', 'mimeType', 'kind']) if (item[key] !== undefined) result[key] = text(item[key], `artifact.${key}`, 2048);
|
|
27
|
+
return result;
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function explicitDissent(value = []) {
|
|
31
|
+
if (!Array.isArray(value) || value.length > 20 || value.some(item => typeof item !== 'string' || !item.trim() || Buffer.byteLength(item, 'utf8') > 2048)) fail('invalid_dissent', 'Dissent must contain at most 20 nonempty strings of at most 2048 UTF-8 bytes each');
|
|
32
|
+
const result = value.map(item => item.trim());
|
|
33
|
+
if (Buffer.byteLength(JSON.stringify(result), 'utf8') > 8192) fail('invalid_dissent', 'Explicit dissent must fit within 8192 UTF-8 bytes');
|
|
34
|
+
return result;
|
|
35
|
+
}
|
|
36
|
+
function processIsGone(attempt) {
|
|
37
|
+
if (closedIssuers.has(attempt.issuer)) return true;
|
|
38
|
+
if (attempt.processBootId === PROCESS_BOOT_ID) return false;
|
|
39
|
+
try { process.kill(attempt.processId, 0); return false; }
|
|
40
|
+
catch (error) { return error.code === 'ESRCH'; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Local owner and exact-seat capabilities enter through a trusted transport boundary. */
|
|
44
|
+
export function createRoundtableService({ databasePath = ':memory:', clock = Date.now, verifyCompletion, contextProvider } = {}) {
|
|
45
|
+
const store = createRoundtableStore({ databasePath });
|
|
46
|
+
const authenticated = new WeakMap();
|
|
47
|
+
const issuer = randomUUID();
|
|
48
|
+
const now = () => {
|
|
49
|
+
const value = clock();
|
|
50
|
+
const timestamp = value instanceof Date ? value.getTime() : Number(value);
|
|
51
|
+
if (!Number.isFinite(timestamp)) fail('invalid_clock', 'Clock must return a timestamp');
|
|
52
|
+
return timestamp;
|
|
53
|
+
};
|
|
54
|
+
const owner = actor => { if (actor?.kind !== 'owner') fail('owner_required', 'Only the local owner can perform this action', 403); };
|
|
55
|
+
function authorize(room, actor, participantOnly = false) {
|
|
56
|
+
if (actor?.kind === 'owner' && !participantOnly) return;
|
|
57
|
+
const capability = actor && authenticated.get(actor);
|
|
58
|
+
if (!capability || actor.kind !== 'participant' || actor.roomId !== room.id) fail('seat_forbidden', 'This capability does not authorize the roundtable', 403);
|
|
59
|
+
const invitation = store.inviteById(capability.invitationId);
|
|
60
|
+
if (!invitation || invitation.revoked_at !== null || invitation.expires_at <= now()) fail('invite_invalid', 'The invitation is expired or revoked', 401);
|
|
61
|
+
if (invitation.room_id !== room.id || invitation.seat_id !== actor.seatId || !room.seats.some(s => s.id === actor.seatId)) fail('seat_forbidden', 'Seat is outside the invitation scope', 403);
|
|
62
|
+
}
|
|
63
|
+
function getRoom(id) {
|
|
64
|
+
const room = store.getRoom(text(id, 'roomId', 128));
|
|
65
|
+
if (!room) fail('room_not_found', 'Roundtable does not exist', 404);
|
|
66
|
+
return room;
|
|
67
|
+
}
|
|
68
|
+
function snapshot(room, input = {}) {
|
|
69
|
+
const after = input.afterSeq ?? input.after ?? input.cursor ?? 0;
|
|
70
|
+
const limit = input.limit ?? 100;
|
|
71
|
+
if (!Number.isSafeInteger(after) || after < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) fail('invalid_cursor', 'Invalid message cursor or page limit');
|
|
72
|
+
const messages = store.messages(room.id, after, limit);
|
|
73
|
+
const attempts = [...room.turns, ...(room.currentTurn ? [room.currentTurn] : [])].flatMap(turn => turn.attempts.map(({ issuer: privateIssuer, processId, processBootId, invitationId, ...attempt }) => ({ turnId: turn.id, seatId: turn.seatId, ...attempt })));
|
|
74
|
+
return { room: publicRoom(room), messages, currentTurn: publicTurn(room.currentTurn), tasks: structuredClone(room.tasks), outcome: structuredClone(room.outcome), events: structuredClone(room.events), attempts, lateResults: store.lateEvidence(room.id), nextCursor: messages.at(-1)?.seq ?? after, hasMore: room.nextSeq - 1 > (messages.at(-1)?.seq ?? after) };
|
|
75
|
+
}
|
|
76
|
+
function interrupt(room, reason, timestamp) {
|
|
77
|
+
const turn = room.currentTurn;
|
|
78
|
+
if (turn?.status === 'claimed') {
|
|
79
|
+
const attempt = turn.attempts.at(-1);
|
|
80
|
+
attempt.status = reason === 'turn_timeout' ? 'expired' : 'interrupted';
|
|
81
|
+
attempt.finishedAt = timestamp;
|
|
82
|
+
turn.status = 'interrupted';
|
|
83
|
+
turn.fence += 1;
|
|
84
|
+
event(room, 'attempt_interrupted', timestamp, { turnId: turn.id, attemptId: attempt.id, reason });
|
|
85
|
+
for (const task of room.tasks.filter(t => turn.taskIds.includes(t.id))) task.status = 'interrupted';
|
|
86
|
+
}
|
|
87
|
+
room.status = 'paused'; room.stopReason = reason;
|
|
88
|
+
}
|
|
89
|
+
function refresh(room, timestamp) {
|
|
90
|
+
if (TERMINAL_STATUSES.has(room.status)) return;
|
|
91
|
+
const attempt = room.currentTurn?.attempts.at(-1);
|
|
92
|
+
if (room.currentTurn?.status === 'claimed') {
|
|
93
|
+
const invitation = store.inviteById(attempt.invitationId);
|
|
94
|
+
if (!invitation || invitation.revoked_at !== null || invitation.expires_at <= timestamp) interrupt(room, 'invitation_expired_or_revoked', timestamp);
|
|
95
|
+
else if (processIsGone(attempt)) interrupt(room, 'restart_interrupted', timestamp);
|
|
96
|
+
else if (room.currentTurn.deadline <= timestamp) interrupt(room, 'turn_timeout', timestamp);
|
|
97
|
+
}
|
|
98
|
+
const reason = budgetReason(room, timestamp);
|
|
99
|
+
if (reason && room.status === 'active') interrupt(room, reason, timestamp);
|
|
100
|
+
}
|
|
101
|
+
function transact(input, actor, work, { participantOnly = false, refreshState = true } = {}) {
|
|
102
|
+
const result = store.transaction(() => {
|
|
103
|
+
const room = getRoom(input.roomId);
|
|
104
|
+
authorize(room, actor, participantOnly);
|
|
105
|
+
const before = JSON.stringify(room), revision = room.revision, timestamp = now();
|
|
106
|
+
if (input.expectedRevision !== undefined && input.expectedRevision !== revision) fail('revision_conflict', 'Roundtable changed; read the current revision', 409);
|
|
107
|
+
if (refreshState) refresh(room, timestamp);
|
|
108
|
+
const result = work(room, timestamp);
|
|
109
|
+
if (JSON.stringify(room) !== before) { room.updatedAt = timestamp; store.saveRoom(room, revision); }
|
|
110
|
+
return typeof result === 'function' ? result() : result;
|
|
111
|
+
});
|
|
112
|
+
if (result?.deferredError) fail(result.deferredError.code, result.deferredError.message, result.deferredError.status);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
function requireJoined(room, actor) {
|
|
116
|
+
const seat = room.seats.find(s => s.id === actor.seatId);
|
|
117
|
+
if (seat.joinedAt === null || seat.joinedInvitationId !== authenticated.get(actor).invitationId || seat.sourceApplication !== actor.sourceApplication || seat.sourceSessionId !== actor.sourceSessionId) fail('join_required', 'Join with this invitation/application/session before claiming a turn', 409);
|
|
118
|
+
return seat;
|
|
119
|
+
}
|
|
120
|
+
function assertSeatsReady(room, timestamp) {
|
|
121
|
+
if (room.seats.some(seat => {
|
|
122
|
+
const invitation = seat.joinedInvitationId && store.inviteById(seat.joinedInvitationId);
|
|
123
|
+
return seat.joinedAt === null || !invitation || invitation.revoked_at !== null || invitation.expires_at <= timestamp;
|
|
124
|
+
})) fail('seats_not_joined', 'Every seat must join with an active invitation after its runtime preflight', 409);
|
|
125
|
+
}
|
|
126
|
+
function defer(code, message, status = 409) { return { deferredError: { code, message, status } }; }
|
|
127
|
+
function saveLate(room, input, timestamp) {
|
|
128
|
+
const turn = [...room.turns, ...(room.currentTurn ? [room.currentTurn] : [])].find(t => t.id === input.turnId);
|
|
129
|
+
const attempt = turn?.attempts.find(a => a.id === input.attemptId);
|
|
130
|
+
if (attempt && !attempt.lateResult) {
|
|
131
|
+
const evidence = { turnId: turn.id, attemptId: attempt.id, seatId: turn.seatId, receivedAt: timestamp, body: input.body, bodyHash: hash(input.body), artifacts: artifacts(input.artifacts), verification: boundedJson(input.verification ?? input.reportedVerification, 'verification'), status: 'stale' };
|
|
132
|
+
store.recordLateEvidence(room.id, attempt.id, evidence);
|
|
133
|
+
if (!TERMINAL_STATUSES.has(room.status)) {
|
|
134
|
+
const { body, ...receipt } = evidence;
|
|
135
|
+
attempt.lateResult = receipt;
|
|
136
|
+
event(room, 'stale_result_received', timestamp, { turnId: turn.id, attemptId: attempt.id });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function complete(input, actor) {
|
|
141
|
+
owner(actor);
|
|
142
|
+
const initial = transact(input, actor, room => () => snapshot(room));
|
|
143
|
+
const room = initial.room;
|
|
144
|
+
if (room.mode !== 'collaboration' || room.phase !== 'synthesis' || room.status !== 'waiting_input' || initial.currentTurn) fail('completion_not_ready', 'Complete only after implementation, review and synthesis have finished', 409);
|
|
145
|
+
const apply = confirmation => transact({ ...input, expectedRevision: room.revision }, actor, (current, timestamp) => {
|
|
146
|
+
assertMutable(current);
|
|
147
|
+
if (current.tasks.some(task => task.status !== 'reported_complete')) fail('tasks_incomplete', 'Every implementation and review task must report completion', 409);
|
|
148
|
+
if ((room.binding?.taskId || room.binding?.coordinatedTaskId) && (confirmation?.verified !== true || confirmation.taskStatus !== 'completed')) fail('task_verification_required', 'The authoritative coordinated task has not verified completion', 409);
|
|
149
|
+
current.status = 'concluded'; current.stopReason = null;
|
|
150
|
+
const linkedTask = Boolean(room.binding?.taskId || room.binding?.coordinatedTaskId);
|
|
151
|
+
current.outcome.acceptance = linkedTask ? 'task_verified' : 'human_accepted';
|
|
152
|
+
current.outcome.acceptedAt = timestamp;
|
|
153
|
+
current.outcome.verification.confirmed = linkedTask;
|
|
154
|
+
if (linkedTask) current.outcome.verification.authoritativeEvidence = boundedJson(confirmation.evidence, 'completion evidence');
|
|
155
|
+
event(current, 'owner_completed', timestamp, { acceptance: current.outcome.acceptance });
|
|
156
|
+
return () => snapshot(current);
|
|
157
|
+
});
|
|
158
|
+
if (!room.binding?.taskId && !room.binding?.coordinatedTaskId) return apply(null);
|
|
159
|
+
if (typeof verifyCompletion !== 'function') fail('task_verification_required', 'A Fuli task verification port is required', 409);
|
|
160
|
+
const confirmation = verifyCompletion({ binding: room.binding, roomId: room.id });
|
|
161
|
+
return confirmation && typeof confirmation.then === 'function' ? confirmation.then(apply) : apply(confirmation);
|
|
162
|
+
}
|
|
163
|
+
const service = {
|
|
164
|
+
create(input, actor) {
|
|
165
|
+
owner(actor);
|
|
166
|
+
return store.transaction(() => {
|
|
167
|
+
const key = input.idempotencyKey && text(input.idempotencyKey, 'idempotencyKey', 256), fingerprint = inputHash(input);
|
|
168
|
+
const duplicate = store.getIdempotent('create', key, fingerprint);
|
|
169
|
+
if (duplicate) return duplicate;
|
|
170
|
+
const room = newRoom(input, now());
|
|
171
|
+
store.insertRoom(room);
|
|
172
|
+
const result = snapshot(room);
|
|
173
|
+
if (key) store.saveIdempotent('create', key, fingerprint, result);
|
|
174
|
+
return result;
|
|
175
|
+
});
|
|
176
|
+
},
|
|
177
|
+
list(input = {}, actor) {
|
|
178
|
+
if (actor?.kind !== 'owner') {
|
|
179
|
+
const room = getRoom(actor?.roomId);
|
|
180
|
+
authorize(room, actor);
|
|
181
|
+
return { rooms: [publicRoom(room)] };
|
|
182
|
+
}
|
|
183
|
+
return { rooms: store.listRooms().filter(room => !input.status || room.status === input.status).slice(0, 100).map(publicRoom) };
|
|
184
|
+
},
|
|
185
|
+
read(input, actor) { return transact(input, actor, room => () => snapshot(room, input)); },
|
|
186
|
+
invite(input, actor) {
|
|
187
|
+
owner(actor);
|
|
188
|
+
return transact(input, actor, (room, timestamp) => {
|
|
189
|
+
assertMutable(room);
|
|
190
|
+
const seatId = text(input.seatId, 'seatId', 128);
|
|
191
|
+
if (!room.seats.some(s => s.id === seatId)) fail('seat_not_found', 'Seat does not exist', 404);
|
|
192
|
+
const expiresInMs = input.expiresInMs ?? 3_600_000;
|
|
193
|
+
if (!Number.isSafeInteger(expiresInMs) || expiresInMs < 1000 || expiresInMs > 86_400_000) fail('invalid_expiry', 'Invitation expiry must be between one second and one day');
|
|
194
|
+
const seatToken = randomBytes(32).toString('base64url'), id = randomUUID(), expiresAt = timestamp + expiresInMs;
|
|
195
|
+
store.insertInvite({ id, roomId: room.id, seatId, tokenHash: hash(seatToken), expiresAt });
|
|
196
|
+
event(room, 'invitation_issued', timestamp, { seatId, expiresAt });
|
|
197
|
+
return { invitationId: id, roomId: room.id, seatId, seatToken, expiresAt };
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
revoke(input, actor) {
|
|
201
|
+
owner(actor);
|
|
202
|
+
return transact(input, actor, (room, timestamp) => {
|
|
203
|
+
if (!input.invitationId && !input.seatId) fail('invalid_input', 'Provide invitationId or seatId');
|
|
204
|
+
const revoked = store.revokeInvite(room.id, input.seatId ?? null, input.invitationId ?? null, timestamp);
|
|
205
|
+
if (!TERMINAL_STATUSES.has(room.status)) event(room, 'invitation_revoked', timestamp, { seatId: input.seatId ?? null, revoked });
|
|
206
|
+
if (room.currentTurn?.status === 'claimed') {
|
|
207
|
+
const invitation = store.inviteById(room.currentTurn.attempts.at(-1).invitationId);
|
|
208
|
+
if (invitation?.revoked_at !== null) interrupt(room, 'invitation_revoked', timestamp);
|
|
209
|
+
}
|
|
210
|
+
return () => ({ ...snapshot(room), revoked });
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
authenticate(input) {
|
|
214
|
+
const roomId = text(input.roomId, 'roomId', 128), seatToken = text(input.seatToken, 'seatToken', 256);
|
|
215
|
+
const invitation = store.inviteByHash(hash(seatToken));
|
|
216
|
+
if (!invitation || invitation.room_id !== roomId || invitation.revoked_at !== null || invitation.expires_at <= now()) fail('invite_invalid', 'The invitation is expired, revoked or outside this room', 401);
|
|
217
|
+
const actor = Object.freeze({ kind: 'participant', roomId, seatId: invitation.seat_id, sourceApplication: text(input.sourceApplication, 'sourceApplication', 64), sourceSessionId: text(input.sourceSessionId, 'sourceSessionId', 256) });
|
|
218
|
+
authenticated.set(actor, { invitationId: invitation.id });
|
|
219
|
+
return actor;
|
|
220
|
+
},
|
|
221
|
+
join(input, actor) {
|
|
222
|
+
return transact(input, actor, (room, timestamp) => {
|
|
223
|
+
assertMutable(room);
|
|
224
|
+
if (input.seatId && input.seatId !== actor.seatId) fail('seat_forbidden', 'Join only your authenticated seat', 403);
|
|
225
|
+
const seat = room.seats.find(s => s.id === actor.seatId);
|
|
226
|
+
if (room.currentTurn?.status === 'claimed' && room.currentTurn.seatId === seat.id && seat.sourceSessionId !== actor.sourceSessionId) fail('seat_busy', 'Seat has a current attempt in another session', 409);
|
|
227
|
+
seat.joinedAt = timestamp; seat.joinedInvitationId = authenticated.get(actor).invitationId; seat.sourceApplication = actor.sourceApplication; seat.sourceSessionId = actor.sourceSessionId;
|
|
228
|
+
event(room, 'seat_joined', timestamp, { seatId: seat.id, sourceApplication: actor.sourceApplication, sourceSessionId: actor.sourceSessionId, identityVerified: false });
|
|
229
|
+
return () => snapshot(room);
|
|
230
|
+
}, { participantOnly: true });
|
|
231
|
+
},
|
|
232
|
+
claim(input, actor) {
|
|
233
|
+
const result = transact(input, actor, (room, timestamp) => {
|
|
234
|
+
assertMutable(room); const seat = requireJoined(room, actor);
|
|
235
|
+
if (input.seatId && input.seatId !== actor.seatId) fail('seat_forbidden', 'Claim only your authenticated seat', 403);
|
|
236
|
+
if (room.status !== 'active') return null;
|
|
237
|
+
const turn = room.currentTurn;
|
|
238
|
+
if (!turn || turn.seatId !== seat.id) return null;
|
|
239
|
+
if (turn.status !== 'pending') return null;
|
|
240
|
+
if (turn.attempts.length >= 2) { interrupt(room, 'retry_limit', timestamp); return null; }
|
|
241
|
+
const attemptId = randomUUID(), deadline = Math.min(timestamp + room.limits.turnTimeoutMs, room.startedAt + room.limits.maxDurationMs);
|
|
242
|
+
turn.status = 'claimed'; turn.attemptId = attemptId; turn.fence += 1; turn.deadline = deadline;
|
|
243
|
+
turn.attempts.push({ id: attemptId, fence: turn.fence, status: 'running', startedAt: timestamp, finishedAt: null, sourceApplication: actor.sourceApplication, sourceSessionId: actor.sourceSessionId, issuer, processId: process.pid, processBootId: PROCESS_BOOT_ID, invitationId: authenticated.get(actor).invitationId });
|
|
244
|
+
const tasks = room.tasks.filter(t => turn.taskIds.includes(t.id)), task = tasks[0];
|
|
245
|
+
for (const assigned of tasks) assigned.status = 'running';
|
|
246
|
+
event(room, 'turn_claimed', timestamp, { turnId: turn.id, seatId: seat.id, attemptId });
|
|
247
|
+
const messages = store.recentMessages(room.id, 12);
|
|
248
|
+
return { turnId: turn.id, attemptId, fence: turn.fence, deadline, context: { roomId: room.id, goal: room.goal, mode: room.mode, phase: room.phase, round: room.round, scope: room.scope, seat: publicSeat(seat), messages, tasks: structuredClone(room.tasks), currentTask: structuredClone(task ?? null), currentTasks: structuredClone(tasks), truncated: room.nextSeq - 1 > messages.length, earlierMessagesAvailable: room.nextSeq - 1 > messages.length } };
|
|
249
|
+
}, { participantOnly: true });
|
|
250
|
+
if (!result || !contextProvider || result.context.scope.kind !== 'fuli' || !result.context.seat.shareAgentContext) return result;
|
|
251
|
+
const addContext = context => {
|
|
252
|
+
if (!context || context.status === 'client_not_allowed') { result.context.privateContextUnavailable = context?.guidance ?? 'private_context_not_available'; return result; }
|
|
253
|
+
result.context.privateAgentContext = boundedJson(context, 'private Agent context', 131_072); return result;
|
|
254
|
+
};
|
|
255
|
+
const unavailable = () => { result.context.privateContextUnavailable = 'context_provider_unavailable'; return result; };
|
|
256
|
+
try {
|
|
257
|
+
const context = contextProvider({ binding: getRoom(input.roomId).binding, seat: result.context.seat, actor });
|
|
258
|
+
return context && typeof context.then === 'function' ? context.then(addContext).catch(unavailable) : addContext(context);
|
|
259
|
+
} catch { return unavailable(); }
|
|
260
|
+
},
|
|
261
|
+
submit(input, actor) {
|
|
262
|
+
text(input.body, 'body');
|
|
263
|
+
const key = text(input.idempotencyKey, 'idempotencyKey', 256);
|
|
264
|
+
return transact(input, actor, (room, timestamp) => {
|
|
265
|
+
const fingerprint = inputHash(input), scope = `submit:${room.id}:${actor.seatId}`;
|
|
266
|
+
const duplicate = store.getIdempotent(scope, key, fingerprint);
|
|
267
|
+
if (duplicate) return duplicate;
|
|
268
|
+
const turn = room.currentTurn;
|
|
269
|
+
if (!turn || turn.seatId !== actor.seatId || turn.id !== input.turnId) {
|
|
270
|
+
const historical = [...room.turns, ...(turn ? [turn] : [])].find(t => t.id === input.turnId);
|
|
271
|
+
if (historical && historical.seatId !== actor.seatId) fail('seat_forbidden', 'Submit only your own turn', 403);
|
|
272
|
+
saveLate(room, input, timestamp);
|
|
273
|
+
return defer('stale_turn', 'This turn is no longer current');
|
|
274
|
+
}
|
|
275
|
+
requireJoined(room, actor);
|
|
276
|
+
if (turn.status !== 'claimed' || turn.attemptId !== input.attemptId || turn.fence !== input.fence || turn.deadline <= timestamp || TERMINAL_STATUSES.has(room.status)) {
|
|
277
|
+
saveLate(room, input, timestamp);
|
|
278
|
+
return defer('stale_fence', 'This attempt expired or its fence is stale');
|
|
279
|
+
}
|
|
280
|
+
if (!['active', 'paused'].includes(room.status)) return defer('room_not_active', 'This room cannot accept the attempt');
|
|
281
|
+
const reported = boundedJson(input.verification ?? input.reportedVerification, 'verification');
|
|
282
|
+
const resultStatus = input.status ?? input.outcomeStatus ?? 'completed';
|
|
283
|
+
if (!['completed', 'failed', 'blocked', 'cancelled'].includes(resultStatus)) fail('invalid_status', 'Unknown result status');
|
|
284
|
+
const artifactReferences = artifacts(input.artifacts), declaredDissent = explicitDissent(input.dissent);
|
|
285
|
+
if (resultStatus === 'completed' && room.phase === 'implementation' && !artifactReferences.length) fail('implementation_artifacts_required', 'Completed implementation must include at least one artifact reference; add artifacts and resubmit this same attempt');
|
|
286
|
+
if (resultStatus === 'completed' && room.phase === 'review' && typeof reported?.passed !== 'boolean') fail('review_verification_required', 'Completed review must include verification.passed as true or false; provide the explicit verdict and resubmit this same attempt');
|
|
287
|
+
const kind = input.kind ?? ({ discussion: 'proposal', planning: 'proposal', implementation: 'result', review: 'review', synthesis: 'result' }[room.phase]);
|
|
288
|
+
if (!['proposal', 'question', 'review', 'handoff', 'result', 'dissent'].includes(kind)) fail('invalid_kind', 'Unknown participant message kind');
|
|
289
|
+
const actual = input.actual ? { sourceApplication: actor.sourceApplication,
|
|
290
|
+
reportedApplication: input.actual.sourceApplication ? text(input.actual.sourceApplication, 'actual.sourceApplication', 64) : null,
|
|
291
|
+
applicationLabel: input.actual.applicationLabel ? text(input.actual.applicationLabel, 'actual.applicationLabel', 128) : null,
|
|
292
|
+
provider: input.actual.provider ? text(input.actual.provider, 'actual.provider', 128) : null,
|
|
293
|
+
model: input.actual.model ? text(input.actual.model, 'actual.model', 256) : null,
|
|
294
|
+
sessionId: input.actual.sessionId ? text(input.actual.sessionId, 'actual.sessionId', 256) : null,
|
|
295
|
+
remoteTaskId: input.actual.remoteTaskId ? text(input.actual.remoteTaskId, 'actual.remoteTaskId', 256) : null,
|
|
296
|
+
evidence: boundedJson(input.actual.evidence ?? input.actual.evidenceLevel, 'actual.evidence', 2048),
|
|
297
|
+
cancellationConfirmed: typeof input.actual.cancellationConfirmed === 'boolean' ? input.actual.cancellationConfirmed : null,
|
|
298
|
+
usage: boundedJson(input.actual.usage, 'actual.usage', 2048), provenance: 'participant_reported', identityVerified: false } : null;
|
|
299
|
+
const message = { id: randomUUID(), seq: room.nextSeq++, seatId: actor.seatId, kind, body: text(input.body, 'body'), artifacts: artifactReferences, dissent: declaredDissent, verification: { reported, confirmed: false }, actual, status: resultStatus, createdAt: timestamp, turnId: turn.id, attemptId: input.attemptId, sourceApplication: actor.sourceApplication, sourceSessionId: actor.sourceSessionId };
|
|
300
|
+
store.appendMessage(room.id, message); room.agentMessageCount += 1;
|
|
301
|
+
const attempt = turn.attempts.at(-1); attempt.status = resultStatus; attempt.finishedAt = timestamp; attempt.messageId = message.id; attempt.actual = actual;
|
|
302
|
+
turn.status = resultStatus;
|
|
303
|
+
const failedReview = room.phase === 'review' && (reported?.passed === false || reported?.status === 'failed');
|
|
304
|
+
const failedResult = resultStatus !== 'completed' || failedReview;
|
|
305
|
+
for (const task of room.tasks.filter(t => turn.taskIds.includes(t.id))) { task.status = failedResult ? resultStatus === 'blocked' ? 'blocked' : 'failed' : 'reported_complete'; task.artifacts = message.artifacts; task.verification = message.verification; }
|
|
306
|
+
if (failedResult) {
|
|
307
|
+
const waiting = resultStatus === 'blocked' && ['waiting_auth', 'waiting_input'].includes(input.blockedReason) ? input.blockedReason : null;
|
|
308
|
+
room.status = waiting ?? 'waiting_input'; room.stopReason = failedReview ? 'review_failed' : waiting ?? `participant_${resultStatus}`;
|
|
309
|
+
event(room, 'attempt_failed', timestamp, { turnId: turn.id, reason: room.stopReason });
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
if (room.phase === 'synthesis') {
|
|
313
|
+
// Earlier objections are immutable public evidence, not editable moderator input.
|
|
314
|
+
const objections = store.recentMessages(room.id, room.nextSeq - 1).filter(prior => prior.kind === 'dissent' || prior.dissent?.length);
|
|
315
|
+
const retainedDissent = objections.flatMap(prior => [...(prior.kind === 'dissent' ? [prior.body] : []), ...(prior.dissent ?? [])]);
|
|
316
|
+
room.outcome = { body: message.body, dissent: [...new Set([...retainedDissent, ...declaredDissent])], dissentReferences: objections.map(prior => ({ messageId: prior.id, seatId: prior.seatId, seq: prior.seq })), artifacts: [...room.tasks.flatMap(t => t.artifacts), ...message.artifacts], verification: message.verification, acceptance: 'pending', createdAt: timestamp };
|
|
317
|
+
}
|
|
318
|
+
finishTurn(room, timestamp);
|
|
319
|
+
const reason = budgetReason(room, timestamp); if (reason && room.status === 'active') interrupt(room, reason, timestamp);
|
|
320
|
+
}
|
|
321
|
+
return () => {
|
|
322
|
+
const result = { message, room: publicRoom(room), currentTurn: publicTurn(room.currentTurn), outcome: structuredClone(room.outcome), tasks: structuredClone(room.tasks) };
|
|
323
|
+
store.saveIdempotent(scope, key, fingerprint, result); return result;
|
|
324
|
+
};
|
|
325
|
+
}, { participantOnly: true });
|
|
326
|
+
},
|
|
327
|
+
control(input, actor) {
|
|
328
|
+
owner(actor);
|
|
329
|
+
if (input.action === 'complete') return complete(input, actor);
|
|
330
|
+
return transact(input, actor, (room, timestamp) => {
|
|
331
|
+
assertMutable(room);
|
|
332
|
+
switch (input.action) {
|
|
333
|
+
case 'start':
|
|
334
|
+
if (room.status !== 'draft') fail('invalid_transition', 'Start only a draft roundtable', 409);
|
|
335
|
+
assertSeatsReady(room, timestamp);
|
|
336
|
+
room.status = 'active'; room.startedAt = timestamp; room.stopReason = null; scheduleTurn(room, timestamp); break;
|
|
337
|
+
case 'pause':
|
|
338
|
+
if (room.status !== 'active') fail('invalid_transition', 'Pause only an active roundtable', 409);
|
|
339
|
+
room.status = 'paused'; room.stopReason = 'owner_paused'; break;
|
|
340
|
+
case 'resume': {
|
|
341
|
+
if (!['paused', 'waiting_input', 'waiting_auth'].includes(room.status)) fail('invalid_transition', 'Resume only a paused or waiting roundtable', 409);
|
|
342
|
+
const reason = budgetReason(room, timestamp); if (reason) fail('budget_exhausted', `Cannot resume: ${reason}`, 409);
|
|
343
|
+
assertSeatsReady(room, timestamp);
|
|
344
|
+
if (room.currentTurn && !['pending', 'claimed'].includes(room.currentTurn.status)) fail('retry_required', 'Explicit retry or skip is required for interrupted/failed work', 409);
|
|
345
|
+
if (room.phase === 'synthesis' && !room.currentTurn && room.outcome) fail('completion_required', 'Owner completion is required after synthesis', 409);
|
|
346
|
+
room.status = 'active'; room.stopReason = null; scheduleTurn(room, timestamp); break;
|
|
347
|
+
}
|
|
348
|
+
case 'stop':
|
|
349
|
+
if (room.currentTurn) {
|
|
350
|
+
const turn = room.currentTurn, attempt = turn.attempts.at(-1);
|
|
351
|
+
if (attempt?.status === 'running') { attempt.status = 'cancellation_requested'; attempt.finishedAt = timestamp; }
|
|
352
|
+
turn.status = 'cancelled'; turn.fence += 1; room.turns.push(turn); room.currentTurn = null;
|
|
353
|
+
}
|
|
354
|
+
room.status = 'cancelled'; room.stopReason = 'owner_stopped'; event(room, 'cancellation_requested', timestamp, { confirmed: false }); break;
|
|
355
|
+
case 'retry_turn': {
|
|
356
|
+
if (!['paused', 'waiting_input', 'waiting_auth'].includes(room.status) || !room.currentTurn || ['pending', 'claimed'].includes(room.currentTurn.status)) fail('invalid_transition', 'Retry only interrupted or failed work', 409);
|
|
357
|
+
if (room.currentTurn.attempts.length >= 2) fail('retry_limit', 'A turn permits at most one explicit retry', 409);
|
|
358
|
+
const reason = budgetReason(room, timestamp); if (reason) fail('budget_exhausted', `Cannot retry: ${reason}`, 409);
|
|
359
|
+
room.currentTurn.status = 'pending'; room.currentTurn.attemptId = null; room.currentTurn.deadline = null; room.currentTurn.fence += 1;
|
|
360
|
+
room.status = 'active'; room.stopReason = null; break;
|
|
361
|
+
}
|
|
362
|
+
case 'skip_turn':
|
|
363
|
+
if (!room.currentTurn || room.currentTurn.status === 'claimed') fail('invalid_transition', 'A running turn must finish or expire before it can be skipped', 409);
|
|
364
|
+
text(input.reason, 'skip reason', 2048);
|
|
365
|
+
if (['implementation', 'review', 'synthesis'].includes(room.phase)) fail('required_work', 'Required implementation, review and synthesis cannot be skipped', 409);
|
|
366
|
+
event(room, 'turn_skipped', timestamp, { turnId: room.currentTurn.id, reason: input.reason }); room.currentTurn.status = 'skipped'; finishTurn(room, timestamp); break;
|
|
367
|
+
case 'advance_phase':
|
|
368
|
+
if (room.phase !== 'discussion' || room.currentTurn?.status === 'claimed' || room.phaseIndex !== 0 || room.round === 1) fail('invalid_transition', 'Advance after a complete discussion round and before the next claim', 409);
|
|
369
|
+
if (room.currentTurn) { room.currentTurn.status = 'cancelled'; room.turns.push(room.currentTurn); room.currentTurn = null; }
|
|
370
|
+
room.phase = room.mode === 'discussion' ? 'synthesis' : 'planning'; room.phaseIndex = 0; scheduleTurn(room, timestamp); break;
|
|
371
|
+
case 'next_round':
|
|
372
|
+
if (room.phase !== 'discussion' || !room.currentTurn || room.currentTurn.status !== 'pending' || room.phaseIndex !== 0) fail('invalid_transition', 'Discussion rounds advance automatically after each seat has a turn', 409);
|
|
373
|
+
break;
|
|
374
|
+
case 'update_limits':
|
|
375
|
+
room.limits = normalizeLimits({ ...room.limits, ...input.limits }); break;
|
|
376
|
+
default: fail('invalid_action', 'Unknown roundtable action');
|
|
377
|
+
}
|
|
378
|
+
event(room, 'owner_control', timestamp, { action: input.action });
|
|
379
|
+
return () => snapshot(room);
|
|
380
|
+
});
|
|
381
|
+
},
|
|
382
|
+
addHumanMessage(input, actor) {
|
|
383
|
+
owner(actor);
|
|
384
|
+
return transact(input, actor, (room, timestamp) => {
|
|
385
|
+
assertMutable(room);
|
|
386
|
+
const key = input.idempotencyKey && text(input.idempotencyKey, 'idempotencyKey', 256), fingerprint = inputHash(input), scope = `human:${room.id}`;
|
|
387
|
+
const duplicate = store.getIdempotent(scope, key, fingerprint); if (duplicate) return duplicate;
|
|
388
|
+
const message = { id: randomUUID(), seq: room.nextSeq++, seatId: null, kind: 'human', body: text(input.body, 'body'), artifacts: artifacts(input.artifacts), verification: null, actual: null, status: 'input', createdAt: timestamp, turnId: null, attemptId: null, sourceApplication: 'local-owner', sourceSessionId: null };
|
|
389
|
+
store.appendMessage(room.id, message);
|
|
390
|
+
return () => { const result = { ...snapshot(room), message }; if (key) store.saveIdempotent(scope, key, fingerprint, result); return result; };
|
|
391
|
+
});
|
|
392
|
+
},
|
|
393
|
+
close() { closedIssuers.add(issuer); store.close(); }
|
|
394
|
+
};
|
|
395
|
+
return service;
|
|
396
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { fail } from './domain.js';
|
|
5
|
+
|
|
6
|
+
/** Collaboration persistence owns transactions, CAS, sequence and capability records. */
|
|
7
|
+
export function createRoundtableStore({ databasePath = ':memory:' } = {}) {
|
|
8
|
+
if (databasePath !== ':memory:') mkdirSync(dirname(databasePath), { recursive: true });
|
|
9
|
+
const database = new DatabaseSync(databasePath);
|
|
10
|
+
database.exec(`
|
|
11
|
+
PRAGMA journal_mode = WAL;
|
|
12
|
+
PRAGMA busy_timeout = 5000;
|
|
13
|
+
PRAGMA foreign_keys = ON;
|
|
14
|
+
CREATE TABLE IF NOT EXISTS roundtable_rooms (id TEXT PRIMARY KEY, revision INTEGER NOT NULL, state TEXT NOT NULL);
|
|
15
|
+
CREATE TABLE IF NOT EXISTS roundtable_messages (room_id TEXT NOT NULL REFERENCES roundtable_rooms(id), seq INTEGER NOT NULL, id TEXT NOT NULL UNIQUE, data TEXT NOT NULL, PRIMARY KEY(room_id,seq));
|
|
16
|
+
CREATE TABLE IF NOT EXISTS roundtable_invites (id TEXT PRIMARY KEY, room_id TEXT NOT NULL REFERENCES roundtable_rooms(id), seat_id TEXT NOT NULL, token_hash TEXT NOT NULL UNIQUE, expires_at INTEGER NOT NULL, revoked_at INTEGER);
|
|
17
|
+
CREATE TABLE IF NOT EXISTS roundtable_idempotency (scope TEXT NOT NULL, key TEXT NOT NULL, payload_hash TEXT NOT NULL, response TEXT NOT NULL, PRIMARY KEY(scope,key));
|
|
18
|
+
CREATE TABLE IF NOT EXISTS roundtable_late_evidence (room_id TEXT NOT NULL REFERENCES roundtable_rooms(id), attempt_id TEXT NOT NULL, data TEXT NOT NULL, PRIMARY KEY(room_id,attempt_id));
|
|
19
|
+
`);
|
|
20
|
+
let closed = false;
|
|
21
|
+
const decode = row => row ? JSON.parse(row.state) : null;
|
|
22
|
+
return {
|
|
23
|
+
transaction(work) {
|
|
24
|
+
if (closed) fail('store_closed', 'Roundtable service is closed', 503);
|
|
25
|
+
database.exec('BEGIN IMMEDIATE');
|
|
26
|
+
try { const result = work(); database.exec('COMMIT'); return result; }
|
|
27
|
+
catch (error) { database.exec('ROLLBACK'); throw error; }
|
|
28
|
+
},
|
|
29
|
+
getRoom(id) { return decode(database.prepare('SELECT state FROM roundtable_rooms WHERE id=?').get(id)); },
|
|
30
|
+
listRooms() { return database.prepare('SELECT state FROM roundtable_rooms ORDER BY rowid DESC').all().map(decode); },
|
|
31
|
+
insertRoom(room) { database.prepare('INSERT INTO roundtable_rooms(id,revision,state) VALUES(?,?,?)').run(room.id, room.revision, JSON.stringify(room)); },
|
|
32
|
+
saveRoom(room, expectedRevision) {
|
|
33
|
+
room.revision = expectedRevision + 1;
|
|
34
|
+
const changed = database.prepare('UPDATE roundtable_rooms SET revision=?,state=? WHERE id=? AND revision=?').run(room.revision, JSON.stringify(room), room.id, expectedRevision);
|
|
35
|
+
if (changed.changes !== 1) fail('revision_conflict', 'Roundtable changed; read the current revision', 409);
|
|
36
|
+
},
|
|
37
|
+
appendMessage(roomId, message) { database.prepare('INSERT INTO roundtable_messages(room_id,seq,id,data) VALUES(?,?,?,?)').run(roomId, message.seq, message.id, JSON.stringify(message)); },
|
|
38
|
+
messages(roomId, afterSeq = 0, limit = 100) { return database.prepare('SELECT data FROM roundtable_messages WHERE room_id=? AND seq>? ORDER BY seq LIMIT ?').all(roomId, afterSeq, limit).map(row => JSON.parse(row.data)); },
|
|
39
|
+
recentMessages(roomId, limit = 12) { return database.prepare('SELECT data FROM roundtable_messages WHERE room_id=? ORDER BY seq DESC LIMIT ?').all(roomId, limit).reverse().map(row => JSON.parse(row.data)); },
|
|
40
|
+
recordLateEvidence(roomId, attemptId, evidence) { database.prepare('INSERT OR IGNORE INTO roundtable_late_evidence(room_id,attempt_id,data) VALUES(?,?,?)').run(roomId, attemptId, JSON.stringify(evidence)); },
|
|
41
|
+
lateEvidence(roomId) { return database.prepare('SELECT data FROM roundtable_late_evidence WHERE room_id=? ORDER BY rowid').all(roomId).map(row => JSON.parse(row.data)); },
|
|
42
|
+
getIdempotent(scope, key, payloadHash) {
|
|
43
|
+
if (!key) return null;
|
|
44
|
+
const row = database.prepare('SELECT payload_hash,response FROM roundtable_idempotency WHERE scope=? AND key=?').get(scope, key);
|
|
45
|
+
if (!row) return null;
|
|
46
|
+
if (row.payload_hash !== payloadHash) fail('idempotency_conflict', 'Idempotency key was used for different input', 409);
|
|
47
|
+
return JSON.parse(row.response);
|
|
48
|
+
},
|
|
49
|
+
saveIdempotent(scope, key, payloadHash, response) { database.prepare('INSERT INTO roundtable_idempotency(scope,key,payload_hash,response) VALUES(?,?,?,?)').run(scope, key, payloadHash, JSON.stringify(response)); },
|
|
50
|
+
insertInvite(invite) { database.prepare('INSERT INTO roundtable_invites(id,room_id,seat_id,token_hash,expires_at,revoked_at) VALUES(?,?,?,?,?,NULL)').run(invite.id, invite.roomId, invite.seatId, invite.tokenHash, invite.expiresAt); },
|
|
51
|
+
inviteByHash(hash) { return database.prepare('SELECT * FROM roundtable_invites WHERE token_hash=?').get(hash) ?? null; },
|
|
52
|
+
inviteById(id) { return database.prepare('SELECT * FROM roundtable_invites WHERE id=?').get(id) ?? null; },
|
|
53
|
+
revokeInvite(roomId, seatId, inviteId, now) {
|
|
54
|
+
if (inviteId) return database.prepare('UPDATE roundtable_invites SET revoked_at=? WHERE room_id=? AND id=? AND revoked_at IS NULL').run(now, roomId, inviteId).changes;
|
|
55
|
+
return database.prepare('UPDATE roundtable_invites SET revoked_at=? WHERE room_id=? AND seat_id=? AND revoked_at IS NULL').run(now, roomId, seatId).changes;
|
|
56
|
+
},
|
|
57
|
+
close() { if (!closed) { closed = true; database.close(); } }
|
|
58
|
+
};
|
|
59
|
+
}
|