@milpa/comal 0.1.7 → 0.2.0
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 +19 -1
- package/backend/contracts/remote-commands-v1.json +12 -0
- package/governance/chrome-task-v10.json +323 -0
- package/governance/chrome-task-v11.json +355 -0
- package/governance/chrome-task-v12.json +387 -0
- package/governance/chrome-task-v13.json +419 -0
- package/governance/chrome-task-v3.json +99 -0
- package/governance/chrome-task-v4.json +131 -0
- package/governance/chrome-task-v5.json +163 -0
- package/governance/chrome-task-v6.json +195 -0
- package/governance/chrome-task-v7.json +227 -0
- package/governance/chrome-task-v8.json +259 -0
- package/governance/chrome-task-v9.json +291 -0
- package/governance/source-task-v1.json +1 -0
- package/governance/source-task-v2.json +1 -0
- package/package.json +14 -1
- package/src/agent-recovery-proof.mjs +12 -1
- package/src/agent.mjs +94 -30
- package/src/audit.mjs +21 -6
- package/src/auto-guard.mjs +48 -1
- package/src/chrome-candidate-recovery-proof.mjs +47 -0
- package/src/chrome-recovery-proof.mjs +6 -2
- package/src/chrome-source-syntax.mjs +93 -0
- package/src/cli.mjs +158 -54
- package/src/codex-provider.mjs +3 -2
- package/src/connection-settings.mjs +204 -0
- package/src/context-pressure.mjs +2 -1
- package/src/durable-agent-runs.mjs +5 -11
- package/src/event-channels.mjs +8 -0
- package/src/file-recovery-proof.mjs +11 -3
- package/src/file-recovery.mjs +24 -0
- package/src/gemini-provider.mjs +6 -1
- package/src/git-agent-revision.mjs +12 -0
- package/src/git-agent-workspaces.mjs +143 -0
- package/src/git-integration-lineage.mjs +20 -0
- package/src/git-integration-operations.mjs +200 -0
- package/src/git-integration-recovery-proof.mjs +70 -0
- package/src/git-page-cli-bridge.mjs +61 -0
- package/src/git-page-evidence.mjs +30 -0
- package/src/git-page-identity.mjs +10 -0
- package/src/git-page-proof.mjs +96 -0
- package/src/git-page-settlement.mjs +21 -0
- package/src/git-page-verification.mjs +134 -0
- package/src/git-plumbing.mjs +212 -0
- package/src/git-process-worker.mjs +50 -0
- package/src/git-source.mjs +110 -0
- package/src/git-workspaces.mjs +519 -0
- package/src/goals.mjs +13 -7
- package/src/house.mjs +246 -20
- package/src/i18n/cli-es.mjs +4 -0
- package/src/i18n/es.mjs +35 -0
- package/src/judges.mjs +27 -11
- package/src/mcp-runtime.mjs +15 -3
- package/src/model-controls.mjs +11 -1
- package/src/model-deadline.mjs +4 -3
- package/src/native-refusal.mjs +38 -0
- package/src/onboarding.mjs +88 -24
- package/src/openai-provider.mjs +14 -7
- package/src/operations.mjs +9 -2
- package/src/output-recovery.mjs +48 -0
- package/src/peer-tools.mjs +18 -8
- package/src/permission-controls.mjs +232 -0
- package/src/permission-rules.mjs +312 -0
- package/src/permissions.mjs +113 -30
- package/src/plugin-catalog.mjs +84 -0
- package/src/plugin-config.mjs +48 -6
- package/src/plugin-controls.mjs +332 -0
- package/src/plugin-notices.mjs +88 -0
- package/src/plugin-trust.mjs +104 -0
- package/src/plugins/chrome-lab/bridge.mjs +38 -12
- package/src/plugins/chrome-lab/client.mjs +103 -25
- package/src/plugins/chrome-lab/diagnostics.mjs +19 -0
- package/src/plugins/chrome-lab/index.mjs +116 -26
- package/src/plugins/chrome-lab/project.mjs +30 -2
- package/src/plugins/chrome-lab/scoped-input.mjs +135 -0
- package/src/plugins/chrome-lab/worker-diagnostics.mjs +51 -0
- package/src/plugins/open-relay/actions.mjs +205 -0
- package/src/plugins/open-relay/bound.mjs +47 -0
- package/src/plugins/open-relay/client.mjs +158 -0
- package/src/plugins/open-relay/dossier.mjs +119 -0
- package/src/plugins/open-relay/index.mjs +153 -0
- package/src/plugins/open-relay/lookup.mjs +45 -0
- package/src/plugins/open-relay/notices.mjs +79 -0
- package/src/plugins/open-relay/watch.mjs +127 -0
- package/src/plugins.mjs +72 -29
- package/src/private-fs.mjs +40 -0
- package/src/process-lock.mjs +24 -13
- package/src/provider-auth.mjs +14 -3
- package/src/provider.mjs +11 -2
- package/src/read-only-check.mjs +7 -3
- package/src/scope-ceiling.mjs +56 -0
- package/src/scoped-policy.mjs +39 -0
- package/src/secret-login.mjs +68 -0
- package/src/session-bus.mjs +41 -7
- package/src/session-projections-candidate.mjs +119 -0
- package/src/session-projections.mjs +119 -0
- package/src/session-read-cache.mjs +23 -3
- package/src/sessions.mjs +35 -11
- package/src/source-editor-role.mjs +13 -0
- package/src/source-ownership.mjs +73 -0
- package/src/source-proposal-proof.mjs +55 -0
- package/src/source-proposals.mjs +118 -0
- package/src/source-requests.mjs +148 -0
- package/src/source-task-proof.mjs +24 -0
- package/src/source-task-revision-proof.mjs +44 -0
- package/src/subagents.mjs +187 -87
- package/src/tool-packs.mjs +5 -5
- package/src/tools.mjs +82 -15
- package/src/trusted-context.mjs +5 -1
- package/src/tui/connection-transition.mjs +1 -1
- package/src/tui/controller.mjs +8 -4
- package/src/tui/discovery.mjs +24 -12
- package/src/tui/inbound-blocks.mjs +55 -0
- package/src/tui/present.mjs +504 -0
- package/src/tui/screen.mjs +49 -13
- package/src/windows-private.ps1 +61 -0
- package/src/work-assertions.mjs +6 -2
- package/src/work-plan.mjs +34 -8
- package/src/work-state.mjs +15 -9
- package/src/workflows/chrome-page-cases.mjs +156 -0
- package/src/workflows/chrome-page-checkpoint-reader.mjs +10 -0
- package/src/workflows/chrome-page-diagnostics.mjs +113 -0
- package/src/workflows/chrome-page-law.mjs +48 -0
- package/src/workflows/chrome-page-recovery-host-v13.mjs +14 -0
- package/src/workflows/chrome-page-recovery-v13.mjs +104 -0
- package/src/workflows/chrome-page-task.mjs +429 -0
- package/src/workflows/chrome-page-verifier.mjs +123 -0
- package/src/workflows/chrome-source.mjs +73 -0
- package/src/workflows/chrome-supplied-inputs.mjs +43 -0
- package/src/workflows/chrome-task-adoption.mjs +122 -0
- package/src/workflows/chrome-task.mjs +379 -74
- package/src/workflows/source-task-v1.mjs +124 -0
- package/src/workflows/source-task-v2.mjs +227 -0
- package/src/workflows/source-task.mjs +13 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { t, registerMessages } from './i18n/index.mjs';
|
|
2
|
+
import { authProviderDefinition, acceptsSecretLogin } from './provider-auth.mjs';
|
|
3
|
+
|
|
4
|
+
registerMessages({
|
|
5
|
+
'API key for {provider}': 'Llave de API de {provider}',
|
|
6
|
+
'Subscription key for {provider}': 'Llave de suscripción de {provider}',
|
|
7
|
+
'Hidden field: nothing you type is shown, saved in history or sent to the model. Comal stores it only in its private credential store ({directory}). Esc cancels.': 'Campo oculto: lo que escribas no se muestra, no entra al historial ni se envía al modelo. Comal la guarda sólo en su almacén privado de credenciales ({directory}). Esc cancela.',
|
|
8
|
+
'Type or paste the key.': 'Escribe o pega la llave.',
|
|
9
|
+
'Login cancelled. No key was stored.': 'Login cancelado. No se guardó ninguna llave.',
|
|
10
|
+
'Use /login PROVIDER [--key | --api-key-env VARIABLE]. Never paste keys into the composer.': 'Usa /login PROVEEDOR [--key | --api-key-env VARIABLE]. Nunca pegues llaves en el compositor.',
|
|
11
|
+
'{provider} only supports browser sign-in.': '{provider} sólo admite inicio de sesión en el navegador.',
|
|
12
|
+
'The hidden key field needs the interactive terminal; use --api-key-env VARIABLE here.': 'El campo oculto de la llave necesita la terminal interactiva; aquí usa --api-key-env VARIABLE.',
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const VARIABLE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
16
|
+
|
|
17
|
+
/** Argumentos de /login después del proveedor. Sólo nombres, nunca valores. */
|
|
18
|
+
export function parseLoginArguments(parts) {
|
|
19
|
+
if (!parts.length) return {};
|
|
20
|
+
if (parts.length === 1 && parts[0] === '--key') return { hidden: true };
|
|
21
|
+
if (parts.length === 2 && parts[0] === '--api-key-env' && VARIABLE.test(parts[1])) return { apiKeyEnv: parts[1] };
|
|
22
|
+
throw new Error(t('Use /login PROVIDER [--key | --api-key-env VARIABLE]. Never paste keys into the composer.'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Quita las cabeceras de credencial que el adaptador ya puso con su copia de la
|
|
27
|
+
* llave. credentialFetch añade la vigente; si no, fetch une ambas en
|
|
28
|
+
* "Bearer X, Bearer X" y servidores estrictos (Ollama Cloud) responden 401.
|
|
29
|
+
*/
|
|
30
|
+
export function withoutCredentialHeaders(headers) {
|
|
31
|
+
const entries = headers instanceof Headers ? [...headers.entries()] : Array.isArray(headers) ? headers : Object.entries(headers || {});
|
|
32
|
+
return Object.fromEntries(entries.filter(([name]) => !['authorization', 'x-api-key'].includes(String(name).toLowerCase())));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const cancelled = () => Object.assign(new Error(t('Login cancelled. No key was stored.')), { name: 'AbortError', code: 'ABORT_ERR' });
|
|
36
|
+
|
|
37
|
+
/** Pide la llave en el campo oculto de la TUI. Devuelve null si el humano cancela. */
|
|
38
|
+
export async function promptSecret({ ui, provider, directory, signal }) {
|
|
39
|
+
const p = authProviderDefinition(provider);
|
|
40
|
+
const value = await ui.setupInput({
|
|
41
|
+
title: t(p.subscription ? 'Subscription key for {provider}' : 'API key for {provider}', { provider: p.label }),
|
|
42
|
+
description: t('Hidden field: nothing you type is shown, saved in history or sent to the model. Comal stores it only in its private credential store ({directory}). Esc cancels.', { directory: directory || '~/.config/comal/auth' }),
|
|
43
|
+
secret: true, signal,
|
|
44
|
+
// El campo oculto nunca muestra el mensaje del validador; sólo un aviso genérico.
|
|
45
|
+
validate: text => text.trim() ? true : t('Type or paste the key.'),
|
|
46
|
+
});
|
|
47
|
+
return typeof value === 'string' ? value.trim() : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Login de proveedor con tres entradas posibles para la llave:
|
|
52
|
+
* --api-key-env VAR (variable de entorno), la variable por omisión del proveedor
|
|
53
|
+
* si existe, o el campo oculto de la TUI (siempre con --key). La llave sólo pasa
|
|
54
|
+
* de la TUI al almacén de credenciales: no vuelve en el resultado, no entra en
|
|
55
|
+
* entradas de operaciones del libro y nunca se copia a process.env.
|
|
56
|
+
*/
|
|
57
|
+
export async function loginProvider({ authManager, ui, provider, apiKeyEnv, hidden = false, signal, onEvent, env = authManager.env || process.env }) {
|
|
58
|
+
const p = authProviderDefinition(provider);
|
|
59
|
+
if (hidden && !acceptsSecretLogin(p.id)) throw new Error(t('{provider} only supports browser sign-in.', { provider: p.label }));
|
|
60
|
+
const secretCapable = acceptsSecretLogin(p.id) && p.method !== 'device_code';
|
|
61
|
+
const useEnvironment = apiKeyEnv || !hidden && (!secretCapable || !ui || (p.apiKeyEnv && env[p.apiKeyEnv]));
|
|
62
|
+
if (useEnvironment) return authManager.login(p.id, { apiKeyEnv, signal, onEvent });
|
|
63
|
+
if (!ui) throw new Error(t('The hidden key field needs the interactive terminal; use --api-key-env VARIABLE here.'));
|
|
64
|
+
let secret = await promptSecret({ ui, provider: p.id, directory: authManager.directory, signal });
|
|
65
|
+
if (!secret) throw cancelled();
|
|
66
|
+
try { return await authManager.login(p.id, { secret, signal }); }
|
|
67
|
+
finally { secret = null; }
|
|
68
|
+
}
|
package/src/session-bus.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import net from 'node:net';
|
|
2
2
|
import { peerMessagePurpose, peerMessageHeader } from './peer-message-context.mjs';
|
|
3
|
+
import { admitNotice, noticeCounts, noticeMailbox, noticeMessage, NOTICE_LIMITS } from './plugin-notices.mjs';
|
|
3
4
|
import path from 'node:path';
|
|
4
5
|
import os from 'node:os';
|
|
5
6
|
import { constants } from 'node:fs';
|
|
@@ -23,7 +24,7 @@ function authority(value = {}, cwd) {
|
|
|
23
24
|
const normalize = input => {
|
|
24
25
|
if (!input || typeof input !== 'object' || Array.isArray(input)) return invalid();
|
|
25
26
|
const mode = input.mode === undefined ? 'plan' : input.mode;
|
|
26
|
-
if (!['default', 'acceptEdits', 'plan', 'auto'].includes(mode)) return invalid();
|
|
27
|
+
if (!['default', 'acceptEdits', 'plan', 'auto', 'bypassPermissions'].includes(mode)) return invalid();
|
|
27
28
|
const list = key => {
|
|
28
29
|
const items = input[key] === undefined ? [] : input[key];
|
|
29
30
|
if (!Array.isArray(items) || items.length > 128 || items.some(item => typeof item !== 'string' || !item.length || item.length > 200)) return invalid();
|
|
@@ -311,18 +312,49 @@ export class SessionBus {
|
|
|
311
312
|
return result;
|
|
312
313
|
}
|
|
313
314
|
async pending() {
|
|
314
|
-
|
|
315
|
+
// Los avisos de plugins no dependen del transporte entre sesiones: viven en el libro.
|
|
316
|
+
let notices = null;
|
|
317
|
+
const sessionId = this.getSessionId();
|
|
318
|
+
if (UUID.test(sessionId || '')) { const counts = noticeCounts((await this.store.load(sessionId)).rows, this.inbound); if (counts.total) notices = counts; }
|
|
319
|
+
const extra = notices ? { notices } : {}, urgent = notices?.urgent || 0;
|
|
320
|
+
if (!this.availability.available) return { count: urgent, held: notices?.held || 0, total: notices?.total || 0, ...extra, ...this.availability };
|
|
315
321
|
const id = this.getSessionId(); this._paths(id); const state = mailbox((await this.store.load(id)).rows);
|
|
316
322
|
const held = state.live.filter(item => item.held && !state.released.has(item.message_id)).length;
|
|
317
|
-
return { count: this.inbound==='accept' ? state.live.length - held : 0, held, total: state.live.length };
|
|
323
|
+
return { count: (this.inbound==='accept' ? state.live.length - held : 0) + urgent, held: held + (notices?.held || 0), total: state.live.length + (notices?.total || 0), ...extra };
|
|
324
|
+
}
|
|
325
|
+
/** Encola un aviso de plugin ya normalizado (plugin-notices.mjs). Nunca es intención humana. */
|
|
326
|
+
async notify(normalized) {
|
|
327
|
+
const id = this.getSessionId();
|
|
328
|
+
if (!UUID.test(id || '')) return { status: 'no_session' };
|
|
329
|
+
const result = await this.store.workTransaction(id, async () => {
|
|
330
|
+
const admitted = admitNotice((await this.store.load(id)).rows, normalized, { inbound: this.inbound });
|
|
331
|
+
if (admitted.notice) await this.store.append(id, { type: 'plugin_notice_received', source: 'plugin_host', notice: admitted.notice, grants_permission: false });
|
|
332
|
+
return admitted;
|
|
333
|
+
});
|
|
334
|
+
if (result.status === 'accepted' && result.notice.urgent) this._signal();
|
|
335
|
+
this._event({ type: 'plugin_notice_admitted', plugin: normalized.plugin, ref: normalized.ref, status: result.status });
|
|
336
|
+
return { status: result.status, ...(result.notice_id ? { notice_id: result.notice_id } : {}) };
|
|
337
|
+
}
|
|
338
|
+
async _drainNotices(id) {
|
|
339
|
+
if (this.inbound !== 'accept') return [];
|
|
340
|
+
const state = noticeMailbox((await this.store.load(id)).rows), result = [];
|
|
341
|
+
// Urgentes primero; el resto espera al siguiente turno si excede el tope por entrega.
|
|
342
|
+
for (const notice of [...state.ready].sort((a, b) => Number(b.urgent) - Number(a.urgent)).slice(0, NOTICE_LIMITS.perDrain)) {
|
|
343
|
+
const message = noticeMessage(notice);
|
|
344
|
+
const row = await this.store.append(id, { type: 'message', source: 'plugin_notice', notice_id: notice.notice_id, notice, message });
|
|
345
|
+
result.push({ kind: 'plugin_notice', id: notice.notice_id, notice, message, row });
|
|
346
|
+
}
|
|
347
|
+
return result;
|
|
318
348
|
}
|
|
319
349
|
async drain() {
|
|
320
|
-
|
|
350
|
+
const sessionId = this.getSessionId();
|
|
351
|
+
const notices = UUID.test(sessionId || '') && this.inbound === 'accept' ? await this.store.workTransaction(sessionId, () => this._drainNotices(sessionId)) : [];
|
|
352
|
+
if (!this.availability.available) return notices;
|
|
321
353
|
const id = this.getSessionId(); this._paths(id);
|
|
322
|
-
if (this.inbound !== 'accept') return
|
|
354
|
+
if (this.inbound !== 'accept') return notices;
|
|
323
355
|
return this.store.workTransaction(id, async () => {
|
|
324
|
-
if(this.inbound!=='accept')return
|
|
325
|
-
const session = await this.store.load(id), state = mailbox(session.rows), result = [];
|
|
356
|
+
if(this.inbound!=='accept')return notices;
|
|
357
|
+
const session = await this.store.load(id), state = mailbox(session.rows), result = [...notices];
|
|
326
358
|
for (const peer of state.live) {
|
|
327
359
|
if (peer.held && !state.released.has(peer.message_id)) continue;
|
|
328
360
|
const purpose = peerMessagePurpose(peer), header = peerMessageHeader(peer);
|
|
@@ -339,6 +371,8 @@ export class SessionBus {
|
|
|
339
371
|
const count = await this.store.workTransaction(sessionId, async () => {
|
|
340
372
|
const state = mailbox((await this.store.load(sessionId)).rows); let released = 0;
|
|
341
373
|
for (const peer of state.live) if (peer.held && !state.released.has(peer.message_id) && (id === 'all' || id === peer.message_id)) { await this.store.append(sessionId, { type: 'peer_message_released', message_id: peer.message_id }); released++; }
|
|
374
|
+
const notices = noticeMailbox((await this.store.load(sessionId)).rows);
|
|
375
|
+
for (const notice of notices.live) if (notice.held && !notices.released.has(notice.notice_id) && (id === 'all' || id === notice.notice_id)) { await this.store.append(sessionId, { type: 'plugin_notice_released', notice_id: notice.notice_id }); released++; }
|
|
342
376
|
return released;
|
|
343
377
|
});
|
|
344
378
|
if (count && this.inbound === 'accept') this._signal(); return { released: count };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Prospective host-only reader. Not imported by SessionStore, CLI or a workflow.
|
|
2
|
+
// A projection is data, not execution/audit/permission authority.
|
|
3
|
+
import {readFile} from 'node:fs/promises';
|
|
4
|
+
import {performance} from 'node:perf_hooks';
|
|
5
|
+
|
|
6
|
+
const jsonBytes=value=>Buffer.byteLength(JSON.stringify(value));
|
|
7
|
+
const validHash=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
|
|
8
|
+
const defaultState=()=>({stage:'new',status:'ready'});
|
|
9
|
+
// Preserve the original SessionStore fold operations and their JS exceptions.
|
|
10
|
+
// Only its private accumulated fold is reused; callers never receive it. A
|
|
11
|
+
// string messages.slice() can succeed, then messages.push() can fail, or reset
|
|
12
|
+
// can restore an array. No new schema is imposed on these records.
|
|
13
|
+
function foldCompatibility(rows,header,prior){
|
|
14
|
+
let messages=prior?(Array.isArray(prior.messages)?prior.messages.slice():prior.messages):[];
|
|
15
|
+
const historyMessages=prior?prior.historyMessages.slice():[];
|
|
16
|
+
let metadata=prior?prior.metadata:header,goal=prior?.goal,queue=prior?.queue,draft=prior?prior.draft:'',draftAttachments=prior?prior.draftAttachments:[],compaction=prior?.compaction,projection=prior?.projection;
|
|
17
|
+
for (const row of rows) {
|
|
18
|
+
if (row.type === 'reset') { messages = []; historyMessages.length = 0; compaction = undefined; projection = undefined; }
|
|
19
|
+
if (row.type === 'compaction') { messages = row.messages.slice(); compaction = row; }
|
|
20
|
+
if (row.type === 'context_projection') { messages = row.messages.slice(); projection = row; compaction = row; }
|
|
21
|
+
if (row.type === 'context_rebase') { messages = row.messages.slice(); projection = undefined; compaction = undefined; }
|
|
22
|
+
if (row.type === 'message') { messages.push(row.message); historyMessages.push(row.message); }
|
|
23
|
+
if (row.type === 'model') metadata = { ...metadata, model: row.model };
|
|
24
|
+
if (row.type === 'provider') metadata = { ...metadata, provider: row.provider, model: row.model, ...(row.profile !== undefined ? {provider_profile:row.profile} : {}),...(row.connection ? {provider_connection:row.connection} : {}) };
|
|
25
|
+
if (row.type === 'goal') goal = row.goal;
|
|
26
|
+
if (row.type === 'queue') queue = row.snapshot;
|
|
27
|
+
if (row.type === 'draft') { draft = row.text; draftAttachments = row.attachments || []; }
|
|
28
|
+
if (row.type === 'name') metadata = { ...metadata, name: row.name };
|
|
29
|
+
if (row.type === 'parent') metadata = { ...metadata, parent_session_id: row.parent_session_id, subagent_type: row.subagent_type, name: row.description };
|
|
30
|
+
}
|
|
31
|
+
return{messages,historyMessages,metadata,goal,queue,draft,draftAttachments,compaction,projection};
|
|
32
|
+
}
|
|
33
|
+
export function createSessionProjectionReader({store,JournalCache,auditJournal,assertClassifierChannels,maxCachedBytes=16*1024*1024,maxIndexBytes=1024*1024,maxTasks=8,maxReceipts=64}={}) {
|
|
34
|
+
if(!store||typeof store.load!=='function'||typeof store.file!=='function'||!(store.writers instanceof Map)||typeof store.cwd!=='string'||typeof JournalCache!=='function'||typeof auditJournal!=='function'||typeof assertClassifierChannels!=='function')throw new TypeError('Explicit original host store and journal implementations required.');
|
|
35
|
+
for(const [name,value,limit] of [['maxCachedBytes',maxCachedBytes,64*1024*1024],['maxIndexBytes',maxIndexBytes,8*1024*1024],['maxTasks',maxTasks,64],['maxReceipts',maxReceipts,256]])if(!Number.isSafeInteger(value)||value<0||value>limit)throw new TypeError('Invalid bounded projection option: '+name);
|
|
36
|
+
let entry=null,journal=new JournalCache({maxBytes:maxCachedBytes}),pending=Promise.resolve();
|
|
37
|
+
const stats={reads:0,identical:0,extensions:0,full:0,fallbacks:0,failures:0,retained_buffer_bytes:0,retained_index_json_bytes:0,max_cached_buffer_bytes:2*maxCachedBytes,max_index_json_bytes:maxIndexBytes,max_tasks:maxTasks,max_receipts:maxReceipts,last:null};
|
|
38
|
+
// Native trim() admits blank lines at EOF, but appending after them makes
|
|
39
|
+
// them internal parser errors. Such bytes can cache equality, never a prefix.
|
|
40
|
+
const reusablePrefix=(bytes,records)=>{if(bytes.at(-1)!==10)return false;let n=0;for(let p=bytes.indexOf(10);p>=0;p=bytes.indexOf(10,p+1))n++;return n===records;};
|
|
41
|
+
const clear=()=>{entry=null;journal=new JournalCache({maxBytes:maxCachedBytes});stats.retained_buffer_bytes=0;stats.retained_index_json_bytes=0;};
|
|
42
|
+
const index=()=>({header:null,accepted:null,tasks:new Map(),receipts:new Map()});
|
|
43
|
+
const put=(map,key,value,cap)=>{if(map.has(key))map.delete(key);map.set(key,value);while(map.size>cap)map.delete(map.keys().next().value);};
|
|
44
|
+
function fold(view,rows,offset=0){
|
|
45
|
+
for(let i=0;i<rows.length;i++){
|
|
46
|
+
const row=rows[i],n=offset+i;if(!row)continue;
|
|
47
|
+
if(n===0)view.header=row;
|
|
48
|
+
if(row.type==='chrome_task_accepted'&&row.source==='workflow_host')view.accepted=row;
|
|
49
|
+
if(row.type==='chrome_task_checkpoint')put(view.tasks,row.task_digest,{row,n},maxTasks);
|
|
50
|
+
if(['house_proposal','house_started','house_receipt'].includes(row.type)&&typeof row.invocation_id==='string'){
|
|
51
|
+
const tuple=view.receipts.get(row.invocation_id)||{};
|
|
52
|
+
// A bounded recent index. Missing/evicted ancestors never become proof;
|
|
53
|
+
// explicit receipt queries fall back to the original complete reader.
|
|
54
|
+
tuple[row.type]={row,n};put(view.receipts,row.invocation_id,tuple,maxReceipts);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return view;
|
|
58
|
+
}
|
|
59
|
+
const size=(view,compatibility)=>jsonBytes({header:view.header,accepted:view.accepted,tasks:[...view.tasks],receipts:[...view.receipts],compatibility});
|
|
60
|
+
function identity(header,id){if(header?.type!=='session'||header.id!==id||header.cwd!==store.cwd)throw new Error('La sesión pertenece a otro directorio o es inválida.');}
|
|
61
|
+
async function fallback(id,query){
|
|
62
|
+
stats.fallbacks++;clear();const book=await store.load(id);
|
|
63
|
+
const view={header:book.rows[0],accepted:book.rows.findLast(row=>row.type==='chrome_task_accepted'&&row.source==='workflow_host'),tasks:new Map(),receipts:new Map()};
|
|
64
|
+
if(query.kind==='page'){const n=book.rows.findLastIndex(row=>row.type==='chrome_task_checkpoint'&&row.task_digest===query.taskDigest);if(n>=0)view.tasks.set(query.taskDigest,{row:book.rows[n],n});}
|
|
65
|
+
else for(let i=0;i<book.rows.length;i++){const row=book.rows[i];if(row.invocation_id===query.invocationId&&['house_proposal','house_started','house_receipt'].includes(row.type)){const tuple=view.receipts.get(query.invocationId)||{};tuple[row.type]={row,n:i};view.receipts.set(query.invocationId,tuple);}}
|
|
66
|
+
return result(view,book.journalAudit,query);
|
|
67
|
+
}
|
|
68
|
+
function result(view,audit,query){
|
|
69
|
+
const base={session:{id:view.header.id,cwd:view.header.cwd},journalAudit:audit};
|
|
70
|
+
if(query.kind==='page')return structuredClone({...base,accepted:view.accepted??null,checkpoint:view.tasks.get(query.taskDigest)?.row??null,state:view.tasks.get(query.taskDigest)?.row.state||defaultState()});
|
|
71
|
+
const tuple=view.receipts.get(query.invocationId)||{};
|
|
72
|
+
return structuredClone({...base,proposal:tuple.house_proposal?.row??null,started:tuple.house_started?.row??null,receipt:tuple.house_receipt?.row??null,indices:{proposal:tuple.house_proposal?.n??null,started:tuple.house_started?.n??null,receipt:tuple.house_receipt?.n??null}});
|
|
73
|
+
}
|
|
74
|
+
async function read(id,query){
|
|
75
|
+
const began=performance.now();stats.reads++;
|
|
76
|
+
try{
|
|
77
|
+
while(store.writers.has(id))await store.writers.get(id).catch(()=>{});
|
|
78
|
+
const bytes=await readFile(store.file(id));
|
|
79
|
+
if(!maxCachedBytes||!maxIndexBytes||bytes.length>maxCachedBytes)return await fallback(id,query);
|
|
80
|
+
let audit=journal.get(id,bytes),view,records,mode,compatibility;
|
|
81
|
+
if(entry?.id===id&&bytes.equals(entry.bytes)&&audit){view=entry.view;records=entry.records;compatibility=entry.compatibility;mode='identical';stats.identical++;}
|
|
82
|
+
else if(entry?.id===id&&entry.audit.verified&&entry.prefixSafe&&bytes.length>entry.bytes.length&&bytes.subarray(0,entry.bytes.length).equals(entry.bytes)&&bytes.subarray(entry.bytes.length).toString('utf8').trimEnd()){
|
|
83
|
+
// Do not trimStart: a blank line inserted after the verified prefix is
|
|
84
|
+
// invalid in the original full parser and must remain invalid here.
|
|
85
|
+
const suffix=bytes.subarray(entry.bytes.length).toString('utf8').trimEnd().split('\n').map(row=>JSON.parse(row));
|
|
86
|
+
const absolute=new Array(entry.records).concat(suffix);identity(entry.view.header,id);assertClassifierChannels(absolute);
|
|
87
|
+
compatibility=foldCompatibility(suffix,entry.view.header,entry.compatibility);
|
|
88
|
+
audit=journal.extend(id,bytes,absolute);
|
|
89
|
+
if(!audit)return await fallback(id,query);
|
|
90
|
+
view=fold({header:entry.view.header,accepted:entry.view.accepted,tasks:new Map(entry.view.tasks),receipts:new Map([...entry.view.receipts].map(([k,v])=>[k,{...v}]))},suffix,entry.records);
|
|
91
|
+
records=absolute.length;mode='verified-prefix';stats.extensions++;
|
|
92
|
+
} else {
|
|
93
|
+
const rows=bytes.toString('utf8').trim().split('\n').map(row=>JSON.parse(row));identity(rows[0],id);assertClassifierChannels(rows);
|
|
94
|
+
compatibility=foldCompatibility(rows.slice(1),rows[0]);
|
|
95
|
+
audit=audit||auditJournal(rows);view=fold(index(),rows);records=rows.length;mode='full';stats.full++;
|
|
96
|
+
}
|
|
97
|
+
identity(view.header,id);
|
|
98
|
+
const indexBytes=size(view,compatibility);
|
|
99
|
+
if(indexBytes>maxIndexBytes)return await fallback(id,query);
|
|
100
|
+
if(mode!=='identical')journal.set(id,bytes,audit);
|
|
101
|
+
if(audit.consistent){if(mode!=='identical')entry={id,bytes:Buffer.from(bytes),view,records,compatibility,prefixSafe:reusablePrefix(bytes,records),audit:structuredClone(audit)};stats.retained_buffer_bytes=2*bytes.length;stats.retained_index_json_bytes=indexBytes;}
|
|
102
|
+
else clear();
|
|
103
|
+
// A missing recent receipt or task may have been evicted; never credit
|
|
104
|
+
// absence from a bounded index as absence from the original book.
|
|
105
|
+
if(query.kind==='receipt'&&!view.receipts.get(query.invocationId)?.house_proposal||query.kind==='page'&&!view.tasks.has(query.taskDigest)&&view.tasks.size>=maxTasks)return await fallback(id,query);
|
|
106
|
+
stats.last={mode,bytes:bytes.length,records,index_json_bytes:indexBytes,elapsed_ms:performance.now()-began};
|
|
107
|
+
return result(view,audit,query);
|
|
108
|
+
}catch(error){stats.failures++;clear();throw error;}
|
|
109
|
+
finally{stats.last={...stats.last,elapsed_ms:performance.now()-began};}
|
|
110
|
+
}
|
|
111
|
+
// Serialize private cache updates; no journal writer, actor or permission is
|
|
112
|
+
// substituted, and original writer-drain semantics remain per read.
|
|
113
|
+
const serial=(id,query)=>{const next=pending.then(()=>read(id,query));pending=next.catch(()=>{});return next;};
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
readPageState(id,taskDigest){if(!validHash(taskDigest))return Promise.reject(new TypeError('Exact accepted task digest required.'));return serial(id,{kind:'page',taskDigest});},
|
|
116
|
+
readReceipt(id,invocationId){if(typeof invocationId!=='string'||!/^[a-f0-9-]{36}$/.test(invocationId))return Promise.reject(new TypeError('Exact original invocation id required.'));return serial(id,{kind:'receipt',invocationId});},
|
|
117
|
+
status(){return structuredClone(stats);},clear(){return pending.then(()=>clear());},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Narrow host-only reader. A normal page workflow uses it only after explicit host opt-in.
|
|
2
|
+
// A projection is data, not execution/audit/permission authority.
|
|
3
|
+
import {readFile} from 'node:fs/promises';
|
|
4
|
+
import {performance} from 'node:perf_hooks';
|
|
5
|
+
|
|
6
|
+
const jsonBytes=value=>Buffer.byteLength(JSON.stringify(value));
|
|
7
|
+
const validHash=value=>typeof value==='string'&&/^[a-f0-9]{64}$/.test(value);
|
|
8
|
+
const defaultState=()=>({stage:'new',status:'ready'});
|
|
9
|
+
// Preserve the original SessionStore fold operations and their JS exceptions.
|
|
10
|
+
// Only its private accumulated fold is reused; callers never receive it. A
|
|
11
|
+
// string messages.slice() can succeed, then messages.push() can fail, or reset
|
|
12
|
+
// can restore an array. No new schema is imposed on these records.
|
|
13
|
+
function foldCompatibility(rows,header,prior){
|
|
14
|
+
let messages=prior?(Array.isArray(prior.messages)?prior.messages.slice():prior.messages):[];
|
|
15
|
+
const historyMessages=prior?prior.historyMessages.slice():[];
|
|
16
|
+
let metadata=prior?prior.metadata:header,goal=prior?.goal,queue=prior?.queue,draft=prior?prior.draft:'',draftAttachments=prior?prior.draftAttachments:[],compaction=prior?.compaction,projection=prior?.projection;
|
|
17
|
+
for (const row of rows) {
|
|
18
|
+
if (row.type === 'reset') { messages = []; historyMessages.length = 0; compaction = undefined; projection = undefined; }
|
|
19
|
+
if (row.type === 'compaction') { messages = row.messages.slice(); compaction = row; }
|
|
20
|
+
if (row.type === 'context_projection') { messages = row.messages.slice(); projection = row; compaction = row; }
|
|
21
|
+
if (row.type === 'context_rebase') { messages = row.messages.slice(); projection = undefined; compaction = undefined; }
|
|
22
|
+
if (row.type === 'message') { messages.push(row.message); historyMessages.push(row.message); }
|
|
23
|
+
if (row.type === 'model') metadata = { ...metadata, model: row.model };
|
|
24
|
+
if (row.type === 'provider') metadata = { ...metadata, provider: row.provider, model: row.model, ...(row.profile !== undefined ? {provider_profile:row.profile} : {}),...(row.connection ? {provider_connection:row.connection} : {}) };
|
|
25
|
+
if (row.type === 'goal') goal = row.goal;
|
|
26
|
+
if (row.type === 'queue') queue = row.snapshot;
|
|
27
|
+
if (row.type === 'draft') { draft = row.text; draftAttachments = row.attachments || []; }
|
|
28
|
+
if (row.type === 'name') metadata = { ...metadata, name: row.name };
|
|
29
|
+
if (row.type === 'parent') metadata = { ...metadata, parent_session_id: row.parent_session_id, subagent_type: row.subagent_type, name: row.description };
|
|
30
|
+
}
|
|
31
|
+
return{messages,historyMessages,metadata,goal,queue,draft,draftAttachments,compaction,projection};
|
|
32
|
+
}
|
|
33
|
+
export function createSessionProjectionReader({store,JournalCache,auditJournal,assertClassifierChannels,maxCachedBytes=16*1024*1024,maxIndexBytes=1024*1024,maxTasks=8,maxReceipts=64}={}) {
|
|
34
|
+
if(!store||typeof store.load!=='function'||typeof store.file!=='function'||!(store.writers instanceof Map)||typeof store.cwd!=='string'||typeof JournalCache!=='function'||typeof auditJournal!=='function'||typeof assertClassifierChannels!=='function')throw new TypeError('Explicit original host store and journal implementations required.');
|
|
35
|
+
for(const [name,value,limit] of [['maxCachedBytes',maxCachedBytes,64*1024*1024],['maxIndexBytes',maxIndexBytes,8*1024*1024],['maxTasks',maxTasks,64],['maxReceipts',maxReceipts,256]])if(!Number.isSafeInteger(value)||value<0||value>limit)throw new TypeError('Invalid bounded projection option: '+name);
|
|
36
|
+
let entry=null,journal=new JournalCache({maxBytes:maxCachedBytes}),pending=Promise.resolve();
|
|
37
|
+
const stats={reads:0,identical:0,extensions:0,full:0,fallbacks:0,failures:0,retained_buffer_bytes:0,retained_index_json_bytes:0,max_cached_buffer_bytes:2*maxCachedBytes,max_index_json_bytes:maxIndexBytes,max_tasks:maxTasks,max_receipts:maxReceipts,last:null};
|
|
38
|
+
// Native trim() admits blank lines at EOF, but appending after them makes
|
|
39
|
+
// them internal parser errors. Such bytes can cache equality, never a prefix.
|
|
40
|
+
const reusablePrefix=(bytes,records)=>{if(bytes.at(-1)!==10)return false;let n=0;for(let p=bytes.indexOf(10);p>=0;p=bytes.indexOf(10,p+1))n++;return n===records;};
|
|
41
|
+
const clear=()=>{entry=null;journal=new JournalCache({maxBytes:maxCachedBytes});stats.retained_buffer_bytes=0;stats.retained_index_json_bytes=0;};
|
|
42
|
+
const index=()=>({header:null,accepted:null,tasks:new Map(),receipts:new Map()});
|
|
43
|
+
const put=(map,key,value,cap)=>{if(map.has(key))map.delete(key);map.set(key,value);while(map.size>cap)map.delete(map.keys().next().value);};
|
|
44
|
+
function fold(view,rows,offset=0){
|
|
45
|
+
for(let i=0;i<rows.length;i++){
|
|
46
|
+
const row=rows[i],n=offset+i;if(!row)continue;
|
|
47
|
+
if(n===0)view.header=row;
|
|
48
|
+
if(row.type==='chrome_task_accepted'&&row.source==='workflow_host')view.accepted=row;
|
|
49
|
+
if(row.type==='chrome_task_checkpoint')put(view.tasks,row.task_digest,{row,n},maxTasks);
|
|
50
|
+
if(['house_proposal','house_started','house_receipt'].includes(row.type)&&typeof row.invocation_id==='string'){
|
|
51
|
+
const tuple=view.receipts.get(row.invocation_id)||{};
|
|
52
|
+
// A bounded recent index. Missing/evicted ancestors never become proof;
|
|
53
|
+
// explicit receipt queries fall back to the original complete reader.
|
|
54
|
+
tuple[row.type]={row,n};put(view.receipts,row.invocation_id,tuple,maxReceipts);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return view;
|
|
58
|
+
}
|
|
59
|
+
const size=(view,compatibility)=>jsonBytes({header:view.header,accepted:view.accepted,tasks:[...view.tasks],receipts:[...view.receipts],compatibility});
|
|
60
|
+
function identity(header,id){if(header?.type!=='session'||header.id!==id||header.cwd!==store.cwd)throw new Error('La sesión pertenece a otro directorio o es inválida.');}
|
|
61
|
+
async function fallback(id,query){
|
|
62
|
+
stats.fallbacks++;clear();const book=await store.load(id);
|
|
63
|
+
const view={header:book.rows[0],accepted:book.rows.findLast(row=>row.type==='chrome_task_accepted'&&row.source==='workflow_host'),tasks:new Map(),receipts:new Map()};
|
|
64
|
+
if(query.kind==='page'){const n=book.rows.findLastIndex(row=>row.type==='chrome_task_checkpoint'&&row.task_digest===query.taskDigest);if(n>=0)view.tasks.set(query.taskDigest,{row:book.rows[n],n});}
|
|
65
|
+
else for(let i=0;i<book.rows.length;i++){const row=book.rows[i];if(row.invocation_id===query.invocationId&&['house_proposal','house_started','house_receipt'].includes(row.type)){const tuple=view.receipts.get(query.invocationId)||{};tuple[row.type]={row,n:i};view.receipts.set(query.invocationId,tuple);}}
|
|
66
|
+
return result(view,book.journalAudit,query);
|
|
67
|
+
}
|
|
68
|
+
function result(view,audit,query){
|
|
69
|
+
const base={session:{id:view.header.id,cwd:view.header.cwd},journalAudit:audit};
|
|
70
|
+
if(query.kind==='page')return structuredClone({...base,accepted:view.accepted??null,checkpoint:view.tasks.get(query.taskDigest)?.row??null,state:view.tasks.get(query.taskDigest)?.row.state||defaultState()});
|
|
71
|
+
const tuple=view.receipts.get(query.invocationId)||{};
|
|
72
|
+
return structuredClone({...base,proposal:tuple.house_proposal?.row??null,started:tuple.house_started?.row??null,receipt:tuple.house_receipt?.row??null,indices:{proposal:tuple.house_proposal?.n??null,started:tuple.house_started?.n??null,receipt:tuple.house_receipt?.n??null}});
|
|
73
|
+
}
|
|
74
|
+
async function read(id,query){
|
|
75
|
+
const began=performance.now();stats.reads++;
|
|
76
|
+
try{
|
|
77
|
+
while(store.writers.has(id))await store.writers.get(id).catch(()=>{});
|
|
78
|
+
const bytes=await readFile(store.file(id));
|
|
79
|
+
if(!maxCachedBytes||!maxIndexBytes||bytes.length>maxCachedBytes)return await fallback(id,query);
|
|
80
|
+
let audit=journal.get(id,bytes),view,records,mode,compatibility;
|
|
81
|
+
if(entry?.id===id&&bytes.equals(entry.bytes)&&audit){view=entry.view;records=entry.records;compatibility=entry.compatibility;mode='identical';stats.identical++;}
|
|
82
|
+
else if(entry?.id===id&&entry.audit.verified&&entry.prefixSafe&&bytes.length>entry.bytes.length&&bytes.subarray(0,entry.bytes.length).equals(entry.bytes)&&bytes.subarray(entry.bytes.length).toString('utf8').trimEnd()){
|
|
83
|
+
// Do not trimStart: a blank line inserted after the verified prefix is
|
|
84
|
+
// invalid in the original full parser and must remain invalid here.
|
|
85
|
+
const suffix=bytes.subarray(entry.bytes.length).toString('utf8').trimEnd().split('\n').map(row=>JSON.parse(row));
|
|
86
|
+
const absolute=new Array(entry.records).concat(suffix);identity(entry.view.header,id);assertClassifierChannels(absolute);
|
|
87
|
+
compatibility=foldCompatibility(suffix,entry.view.header,entry.compatibility);
|
|
88
|
+
audit=journal.extend(id,bytes,absolute);
|
|
89
|
+
if(!audit)return await fallback(id,query);
|
|
90
|
+
view=fold({header:entry.view.header,accepted:entry.view.accepted,tasks:new Map(entry.view.tasks),receipts:new Map([...entry.view.receipts].map(([k,v])=>[k,{...v}]))},suffix,entry.records);
|
|
91
|
+
records=absolute.length;mode='verified-prefix';stats.extensions++;
|
|
92
|
+
} else {
|
|
93
|
+
const rows=bytes.toString('utf8').trim().split('\n').map(row=>JSON.parse(row));identity(rows[0],id);assertClassifierChannels(rows);
|
|
94
|
+
compatibility=foldCompatibility(rows.slice(1),rows[0]);
|
|
95
|
+
audit=audit||auditJournal(rows);view=fold(index(),rows);records=rows.length;mode='full';stats.full++;
|
|
96
|
+
}
|
|
97
|
+
identity(view.header,id);
|
|
98
|
+
const indexBytes=size(view,compatibility);
|
|
99
|
+
if(indexBytes>maxIndexBytes)return await fallback(id,query);
|
|
100
|
+
if(mode!=='identical')journal.set(id,bytes,audit);
|
|
101
|
+
if(audit.consistent){if(mode!=='identical')entry={id,bytes:Buffer.from(bytes),view,records,compatibility,prefixSafe:reusablePrefix(bytes,records),audit:structuredClone(audit)};stats.retained_buffer_bytes=2*bytes.length;stats.retained_index_json_bytes=indexBytes;}
|
|
102
|
+
else clear();
|
|
103
|
+
// A missing recent receipt or task may have been evicted; never credit
|
|
104
|
+
// absence from a bounded index as absence from the original book.
|
|
105
|
+
if(query.kind==='receipt'&&!view.receipts.get(query.invocationId)?.house_proposal||query.kind==='page'&&!view.tasks.has(query.taskDigest)&&view.tasks.size>=maxTasks)return await fallback(id,query);
|
|
106
|
+
stats.last={mode,bytes:bytes.length,records,index_json_bytes:indexBytes,elapsed_ms:performance.now()-began};
|
|
107
|
+
return result(view,audit,query);
|
|
108
|
+
}catch(error){stats.failures++;clear();throw error;}
|
|
109
|
+
finally{stats.last={...stats.last,elapsed_ms:performance.now()-began};}
|
|
110
|
+
}
|
|
111
|
+
// Serialize private cache updates; no journal writer, actor or permission is
|
|
112
|
+
// substituted, and original writer-drain semantics remain per read.
|
|
113
|
+
const serial=(id,query)=>{const next=pending.then(()=>read(id,query));pending=next.catch(()=>{});return next;};
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
readPageState(id,taskDigest){if(!validHash(taskDigest))return Promise.reject(new TypeError('Exact accepted task digest required.'));return serial(id,{kind:'page',taskDigest});},
|
|
116
|
+
readReceipt(id,invocationId){if(typeof invocationId!=='string'||!/^[a-f0-9-]{36}$/.test(invocationId))return Promise.reject(new TypeError('Exact original invocation id required.'));return serial(id,{kind:'receipt',invocationId});},
|
|
117
|
+
status(){return structuredClone(stats);},clear(){return pending.then(()=>clear());},
|
|
118
|
+
});
|
|
119
|
+
}
|
|
@@ -1,20 +1,40 @@
|
|
|
1
1
|
// Cache computation, never authority or file state. Every lookup is preceded by
|
|
2
2
|
// a new complete file read and requires exact bytes, not stat/mtime metadata.
|
|
3
|
+
import {sealRecord} from './audit.mjs';
|
|
4
|
+
|
|
3
5
|
export class SessionJournalCache {
|
|
4
6
|
#entry;
|
|
5
|
-
constructor({maxBytes=
|
|
7
|
+
constructor({maxBytes=16*1024*1024}={}) {
|
|
6
8
|
if(!Number.isSafeInteger(maxBytes)||maxBytes<0||maxBytes>64*1024*1024)throw new Error('journalCacheBytes debe ser un entero entre 0 y 64 MiB.');
|
|
7
|
-
this.maxBytes=maxBytes;this.stats={hits:0,misses:0,oversized:0,invalid:0};
|
|
9
|
+
this.maxBytes=maxBytes;this.stats={hits:0,misses:0,prefix_hits:0,prefix_records:0,suffix_records:0,oversized:0,invalid:0};
|
|
8
10
|
}
|
|
9
11
|
get(id,bytes) {
|
|
10
12
|
if(this.#entry?.id===id && this.#entry.bytes.equals(bytes)){this.stats.hits++;return structuredClone(this.#entry.audit);}
|
|
11
13
|
this.stats.misses++;return null;
|
|
12
14
|
}
|
|
15
|
+
// Called only after the complete fresh file has been parsed and admitted.
|
|
16
|
+
// Match ALL old bytes through a complete line boundary. Recompute every new
|
|
17
|
+
// seal at its absolute original sequence; never trust a suffix's claimed tip.
|
|
18
|
+
extend(id,bytes,rows) {
|
|
19
|
+
const prior=this.#entry;
|
|
20
|
+
if(!prior||prior.id!==id||!prior.audit.verified||prior.bytes.at(-1)!==10||bytes.length<=prior.bytes.length||rows.length<=prior.audit.records||!bytes.subarray(0,prior.bytes.length).equals(prior.bytes))return null;
|
|
21
|
+
let previous=prior.audit.tip_sha256,sealed=prior.audit.sealed_records,legacy=prior.audit.legacy_records;const violations=[];
|
|
22
|
+
for(let i=prior.audit.records;i<rows.length;i++){
|
|
23
|
+
const {audit,...body}=rows[i],expected=sealRecord(body,i,previous).audit;
|
|
24
|
+
if(audit){
|
|
25
|
+
sealed++;
|
|
26
|
+
if(audit.version!==1||audit.sequence!==i||audit.previous_sha256!==previous||audit.sha256!==expected.sha256)violations.push({row:i,reason:'journal_hash_chain_mismatch'});
|
|
27
|
+
}else{legacy++;if(sealed)violations.push({row:i,reason:'unsealed_record_after_sealed_suffix'});}
|
|
28
|
+
previous=expected.sha256;
|
|
29
|
+
}
|
|
30
|
+
this.stats.prefix_hits++;this.stats.prefix_records+=prior.audit.records;this.stats.suffix_records+=rows.length-prior.audit.records;
|
|
31
|
+
return {scope:'local_journal_consistency',verified:rows.length>0&&!legacy&&!violations.length,consistent:!violations.length,records:rows.length,sealed_records:sealed,legacy_records:legacy,tip_sha256:previous,integrity:'local_hash_chain_not_signed',violations};
|
|
32
|
+
}
|
|
13
33
|
set(id,bytes,audit) {
|
|
14
34
|
if(!this.maxBytes || bytes.length>this.maxBytes){this.#entry=undefined;this.stats.oversized++;return;}
|
|
15
35
|
if(!audit.consistent){this.#entry=undefined;this.stats.invalid++;return;}
|
|
16
36
|
// No mutable buffer/audit handed to a caller can poison a future hit.
|
|
17
37
|
this.#entry={id,bytes:Buffer.from(bytes),audit:structuredClone(audit)};
|
|
18
38
|
}
|
|
19
|
-
status(){return {...this.stats,max_bytes:this.maxBytes,retained_bytes:this.#entry?.bytes.length ||0,entries:this.#entry?1:0,proof:'complete_file_bytes_equal'};}
|
|
39
|
+
status(){return {...this.stats,max_bytes:this.maxBytes,retained_bytes:this.#entry?.bytes.length ||0,entries:this.#entry?1:0,proof:'complete_file_bytes_equal',prefix_proof:'complete_prior_bytes_equal_and_absolute_suffix_seals_replayed'};}
|
|
20
40
|
}
|
package/src/sessions.mjs
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
import { performance } from 'node:perf_hooks';
|
|
2
2
|
import { SessionJournalCache } from './session-read-cache.mjs';
|
|
3
|
-
import {
|
|
3
|
+
import { open, readFile, readdir, unlink, stat, link } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { createHash, randomUUID } from 'node:crypto';
|
|
6
6
|
import { defaultStateDir } from './compat.mjs';
|
|
7
7
|
import { auditJournal, sealRecord } from './audit.mjs';
|
|
8
8
|
import { acquireProcessLock } from './process-lock.mjs';
|
|
9
|
+
import { assertClassifierChannels } from './event-channels.mjs';
|
|
10
|
+
import { ensurePrivateDirectory } from './private-fs.mjs';
|
|
11
|
+
import { t, registerMessages } from './i18n/index.mjs';
|
|
12
|
+
|
|
13
|
+
registerMessages({'Invalid session ID.': 'ID de sesión inválido.'});
|
|
9
14
|
|
|
10
15
|
export class SessionStore {
|
|
11
16
|
#journalCache;
|
|
12
|
-
constructor({ cwd, stateDir = defaultStateDir(cwd), journalCacheBytes=
|
|
17
|
+
constructor({ cwd, stateDir = defaultStateDir(cwd), journalCacheBytes=16*1024*1024 }) {
|
|
13
18
|
this.cwd = cwd;
|
|
14
19
|
this.dir = path.join(stateDir, 'sessions', createHash('sha256').update(cwd).digest('hex').slice(0, 16));
|
|
15
20
|
this.writers = new Map();
|
|
@@ -17,20 +22,28 @@ export class SessionStore {
|
|
|
17
22
|
this.lastLoadProfile=null;
|
|
18
23
|
}
|
|
19
24
|
file(id) {
|
|
20
|
-
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error('
|
|
25
|
+
if (!/^[a-f0-9-]{36}$/.test(id)) throw new Error(t('Invalid session ID.'));
|
|
21
26
|
return path.join(this.dir, `${id}.jsonl`);
|
|
22
27
|
}
|
|
23
28
|
async validateForRecovery(id) {
|
|
24
29
|
// Do not go through load(): append's own writer promise may be awaiting us.
|
|
25
30
|
const rows=(await readFile(this.file(id),'utf8')).trim().split('\n').map(JSON.parse);
|
|
31
|
+
assertClassifierChannels(rows);
|
|
26
32
|
if(rows[0]?.type!=='session' || rows[0]?.id!==id || rows[0]?.cwd!==this.cwd || !auditJournal(rows).consistent)throw Object.assign(new Error('Libro inválido; no se retiró el lock ni se ejecutaron efectos.'),{code:'AUDIT_INVALID'});
|
|
27
33
|
}
|
|
28
34
|
async create(model, provider = 'anthropic') {
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
return this.reserve({model,provider});
|
|
36
|
+
}
|
|
37
|
+
// Host-reserved identities publish a complete fsynced header atomically.
|
|
38
|
+
// An existing identity is observed, never truncated or initialized again.
|
|
39
|
+
async reserve({id=randomUUID(),model,provider='anthropic',created_at=new Date().toISOString()}) {
|
|
40
|
+
await ensurePrivateDirectory(this.dir);
|
|
41
|
+
const target=this.file(id),temporary=path.join(this.dir,'.birth-'+randomUUID()),file=await open(temporary,'wx',0o600);
|
|
42
|
+
try {await file.writeFile(JSON.stringify(sealRecord({type:'session',id,cwd:this.cwd,model,provider,created_at},0))+'\n');await file.sync();}finally{await file.close();}
|
|
43
|
+
try{await link(temporary,target);}catch(error){if(error.code!=='EEXIST')throw error;}finally{await unlink(temporary).catch(()=>{});}
|
|
44
|
+
const session=await this.load(id),header=session.rows[0];
|
|
45
|
+
if(!session.journalAudit.consistent||header.id!==id||header.cwd!==this.cwd||header.model!==model||header.provider!==provider)throw Object.assign(new Error('Reserved native session belongs to another identity/configuration.'),{code:'SESSION_IDENTITY_CONFLICT'});
|
|
46
|
+
return session;
|
|
34
47
|
}
|
|
35
48
|
async load(id) {
|
|
36
49
|
const began=performance.now();
|
|
@@ -41,7 +54,8 @@ export class SessionStore {
|
|
|
41
54
|
const waitDone=performance.now(),bytes=await readFile(this.file(id)),readDone=performance.now();
|
|
42
55
|
this.lastLoadProfile.phase='parse';this.lastLoadProfile.bytes_read=bytes.length;
|
|
43
56
|
const rows=bytes.toString('utf8').trim().split('\n').map(row=>JSON.parse(row)),parseDone=performance.now();
|
|
44
|
-
if (rows[0]?.type !== 'session' || rows[0].cwd !== this.cwd) throw new Error('La sesión pertenece a otro directorio o es inválida.');
|
|
57
|
+
if (rows[0]?.type !== 'session' || rows[0].id!==id || rows[0].cwd !== this.cwd) throw new Error('La sesión pertenece a otro directorio o es inválida.');
|
|
58
|
+
assertClassifierChannels(rows);
|
|
45
59
|
this.lastLoadProfile.phase='fold';
|
|
46
60
|
let messages = []; const historyMessages = []; let metadata = rows[0], goal, queue, draft = '', draftAttachments = [], compaction, projection;
|
|
47
61
|
for (const row of rows.slice(1)) {
|
|
@@ -60,14 +74,24 @@ export class SessionStore {
|
|
|
60
74
|
}
|
|
61
75
|
this.lastLoadProfile.phase='audit';
|
|
62
76
|
const foldDone=performance.now(),cachedAudit=this.#journalCache.get(id,bytes),cacheDone=performance.now();
|
|
63
|
-
const
|
|
77
|
+
const extendedAudit=cachedAudit?null:this.#journalCache.extend(id,bytes,rows);
|
|
78
|
+
const journalAudit=cachedAudit || extendedAudit || auditJournal(rows),auditDone=performance.now();
|
|
64
79
|
if(!cachedAudit)this.#journalCache.set(id,bytes,journalAudit);
|
|
65
|
-
this.lastLoadProfile={pid:process.pid,session_id:id,status:'returned',phase:'complete',bytes_read:bytes.length,rows:rows.length,wait_writers_ms:waitDone-began,read_ms:readDone-waitDone,parse_ms:parseDone-readDone,fold_ms:foldDone-parseDone,cache_compare_ms:cacheDone-foldDone,audit_ms:auditDone-cacheDone,total_ms:performance.now()-began,audit_source:cachedAudit?'verified_identical_bytes':'full_journal_replay',cache:this.#journalCache.status()};
|
|
80
|
+
this.lastLoadProfile={pid:process.pid,session_id:id,status:'returned',phase:'complete',bytes_read:bytes.length,rows:rows.length,wait_writers_ms:waitDone-began,read_ms:readDone-waitDone,parse_ms:parseDone-readDone,fold_ms:foldDone-parseDone,cache_compare_ms:cacheDone-foldDone,audit_ms:auditDone-cacheDone,total_ms:performance.now()-began,audit_source:cachedAudit?'verified_identical_bytes':extendedAudit?'verified_identical_prefix_and_suffix_replay':'full_journal_replay',cache:this.#journalCache.status()};
|
|
66
81
|
return { ...metadata, messages, historyMessages, compaction, projection, rows, journalAudit, goal, queue, draft, draftAttachments };
|
|
67
82
|
}catch(error){this.lastLoadProfile={...this.lastLoadProfile,status:'failed',error_code:error.code || error.name,total_ms:performance.now()-began,cache:this.#journalCache.status()};throw error;}
|
|
68
83
|
}
|
|
84
|
+
// Forensic read only: no provider, messages, goals or control state is folded.
|
|
85
|
+
// Runtime resume must always use load(), including its channel admission guard.
|
|
86
|
+
async readForAudit(id) {
|
|
87
|
+
while (this.writers.has(id)) await this.writers.get(id).catch(() => {});
|
|
88
|
+
const rows = (await readFile(this.file(id), 'utf8')).trim().split('\n').map(JSON.parse);
|
|
89
|
+
if (rows[0]?.type !== 'session' || rows[0].id !== id || rows[0].cwd !== this.cwd) throw new Error('Invalid session identity for audit.');
|
|
90
|
+
return { id, cwd: this.cwd, rows };
|
|
91
|
+
}
|
|
69
92
|
getLoadProfile(){return this.lastLoadProfile?structuredClone(this.lastLoadProfile):null;}
|
|
70
93
|
async append(id, record) {
|
|
94
|
+
assertClassifierChannels([record]);
|
|
71
95
|
const previous = this.writers.get(id) || Promise.resolve();
|
|
72
96
|
const next = previous.catch(() => {}).then(async () => {
|
|
73
97
|
const name = this.file(id), lockPath = `${name}.append.lock`;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {digest} from './operations.mjs';
|
|
2
|
+
export const SOURCE_EDITOR_MECHANISM='comal-source-editor-v1';
|
|
3
|
+
export const SOURCE_EDITOR_NAMES=Object.freeze(['Read','Write','Edit','Glob','Grep','ListDirectory','FileInfo','SourceDraft','SourceRetain','SourceRead','SourcePublish','EvidenceRead']);
|
|
4
|
+
export const sourceEditorContract=contract=>{const {revision,...semantic}=contract;return digest(semantic);};
|
|
5
|
+
export function preparedEditorRole(prepared,index){
|
|
6
|
+
if(!prepared)return null;
|
|
7
|
+
const task=(prepared.operation==='AgentBatch'?prepared.input?.tasks:[prepared.input])?.[index],role=prepared.editor_roles?.[index];
|
|
8
|
+
if(task?.work_tracking!=='parent')return role===undefined||role===null?null:undefined;
|
|
9
|
+
if(task.isolation!=='git'||!role||role.mechanism!==SOURCE_EDITOR_MECHANISM||typeof role.system!=='string'||!role.system.trim()||role.system_digest!==digest(role.system)||!Array.isArray(role.tools)||!role.tools.length||role.tools.some(t=>!SOURCE_EDITOR_NAMES.includes(t.name)||! /^[a-f0-9]{64}$/.test(t.semantic_digest))||new Set(role.tools.map(t=>t.name)).size!==role.tools.length)return undefined;
|
|
10
|
+
const expected=SOURCE_EDITOR_NAMES.filter(name=>!task.allowed_tools||task.allowed_tools.includes(name));if(digest(role.tools.map(t=>t.name))!==digest(expected))return undefined;
|
|
11
|
+
return role;
|
|
12
|
+
}
|
|
13
|
+
export function reservedEditorRole(prepared,index){const role=preparedEditorRole(prepared,index);if(role===undefined)return undefined;if(role===null)return null;const {system,...bound}=role;return bound;}
|