@volter/supercode-teams 0.3.143 → 0.3.144
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 +4 -0
- package/contracts/index.d.ts +1 -0
- package/contracts/index.mjs +1 -1
- package/package.json +2 -2
- package/server/arc2-transactions.mjs +9 -1
- package/server/capture-prefixes.mjs +113 -0
- package/server/capture-transactions.mjs +27 -1
- package/server/harness-routes.mjs +13 -21
- package/server/index.mjs +23 -6
- package/server/store-core.mjs +3 -0
- package/worker/index.mjs +43 -8
package/README.md
CHANGED
|
@@ -192,3 +192,7 @@ with `--agent`. Opening a DM reads the existing session; sending a turn is a sep
|
|
|
192
192
|
## Read server work diagnostics
|
|
193
193
|
|
|
194
194
|
An administrator can run `supercode teams storage work --json` to read recent handler records and retained slow work. Records name normalized HTTP routes, catalog refreshes, transitions and maintenance steps, with SQL call and cursor scan/write counts. The Worker retains up to 32 slow records across a restart. Elapsed milliseconds are not CPU time; hosted per-query timing is unavailable. [Recorded private Worker proof](../../docs/evidence/teams-handler-work-2026-10-07.md).
|
|
195
|
+
|
|
196
|
+
Diagnostic persistence writes at most once every five seconds; the latest window can be lost on restart. A malformed retained history does not stop activation, and the report names that condition in `persistence.restore_error`.
|
|
197
|
+
|
|
198
|
+
Carried capture commits use durable prefix checkpoints. An old session may first return HTTP 503 `carry_checkpoint_pending` with `Retry-After: 1`; retry the same staged commit while the host prepares bounded batches. A ready commit verifies at most 63 old message bodies. `carry_mismatch` refuses a forged prefix, and `carry_checkpoint_unavailable` refuses an unsupported internal checkpoint. The canonical wire digest is unchanged. See [the design](../../docs/adr/0024-capture-prefix-checkpoints.md) and [the measured proof](../../docs/evidence/teams-capture-prefixes-2026-10-07.md).
|
package/contracts/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type ErrorCode =
|
|
|
23
23
|
| 'forbidden' | 'unsupported' | 'machine_offline' | 'controller_busy' | 'conflict'
|
|
24
24
|
| 'resync_required' | 'revision_mismatch' | 'idempotency_conflict' | 'source_revoked'
|
|
25
25
|
| 'source_paused' | 'session_deleted' | 'batch_gap' | 'capture_conflict'
|
|
26
|
+
| 'carry_mismatch' | 'carry_checkpoint_pending' | 'carry_checkpoint_unavailable'
|
|
26
27
|
| 'fidelity_unavailable' | 'rate_limited' | 'request_too_large' | 'internal_error';
|
|
27
28
|
export interface Failure {
|
|
28
29
|
version: 1;
|
package/contracts/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { ContractError, canonicalJson, sha256, validationFailure } from '@volter/teams/server/wire';
|
|
2
2
|
export { ContractError, canonicalJson, validationFailure };
|
|
3
3
|
const ID = /^(srv|team|usr|svc|grp|ses|msg|src|mach|bind|cap|upload|grant|map|op|cont|inv|cred|req|root|inc|rule|att|evt|audit)_[0-9a-f]{32}$/;
|
|
4
|
-
export const ERROR_CODES = Object.freeze(['invalid_request','ambiguous_reference','authentication_required','not_found','forbidden','unsupported','machine_offline','controller_busy','conflict','resync_required','revision_mismatch','idempotency_conflict','source_revoked','source_paused','session_deleted','batch_gap','capture_conflict','fidelity_unavailable','rate_limited','request_too_large','internal_error']);
|
|
4
|
+
export const ERROR_CODES = Object.freeze(['invalid_request','ambiguous_reference','authentication_required','not_found','forbidden','unsupported','machine_offline','controller_busy','conflict','resync_required','revision_mismatch','idempotency_conflict','source_revoked','source_paused','session_deleted','batch_gap','capture_conflict','carry_mismatch','carry_checkpoint_pending','carry_checkpoint_unavailable','fidelity_unavailable','rate_limited','request_too_large','internal_error']);
|
|
5
5
|
export const OPERATIONS = Object.freeze(['me','teams_list','teams_create','list','search','read','follow','descriptor','access','grant','invite','accept_invite','source_create','source_config','enroll','heartbeat','machine_configuration','capture_begin','capture_part','capture_commit','correlate','live_grant','attach','prepare','launch','export']);
|
|
6
6
|
const object = (value, name) => { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ContractError(`${name} must be an object`); return value; };
|
|
7
7
|
const exact = (value, allowed, name) => { for (const key of Object.keys(object(value, name))) if (!allowed.includes(key)) throw new ContractError(`${name}.${key} is unknown`); };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.144",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A machine daemon serving this machine's doors, and a local or remote Teams server for shared session discovery, OpenTelemetry ingestion and access to enrolled machines.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,7 +70,7 @@
|
|
|
70
70
|
"@volter/supercode-health": "^0.1.0",
|
|
71
71
|
"@volter/supercode-terminal": "^0.2.54",
|
|
72
72
|
"@volter/supercode-trace": "^0.1.0",
|
|
73
|
-
"@volter/teams": "0.2.
|
|
73
|
+
"@volter/teams": "0.2.39",
|
|
74
74
|
"@xterm/addon-serialize": "^0.14.0",
|
|
75
75
|
"@xterm/headless": "^6.0.0",
|
|
76
76
|
"protobufjs": "8.8.0",
|
|
@@ -34,7 +34,15 @@ export function correlateSessions(db,args){
|
|
|
34
34
|
if(members.some(row=>!manageable(row)))fail('caller must manage every constituent','forbidden',403);
|
|
35
35
|
if(members.some(row=>row.deleted_at!=null)){
|
|
36
36
|
const deletedAt=members.map(row=>row.deleted_at).filter(value=>value!=null).reduce((a,b)=>Math.min(a,b)),team=get('SELECT journal_revision FROM teams WHERE id=?',[args.teamId]),revision=team.journal_revision+1,canonical=rootOf(members.reduce((best,row)=>row.created_at<best.created_at?row:best,members[0]));
|
|
37
|
-
for(const member of members){
|
|
37
|
+
for(const member of members){
|
|
38
|
+
run('UPDATE sessions SET deleted_at=?,revision=? WHERE team_id=? AND id=?',[deletedAt,revision,args.teamId,member.id]);
|
|
39
|
+
run('UPDATE origins SET deleted_at=? WHERE team_id=? AND session_id=?',[deletedAt,args.teamId,member.id]);
|
|
40
|
+
// Correlation propagates a tombstone without going through DELETE /sessions.
|
|
41
|
+
// Retire only metadata here; the alarm removes each generation in bounded batches.
|
|
42
|
+
run('INSERT OR IGNORE INTO capture_prefix_garbage SELECT team_id,session_id,source_id,generation FROM capture_prefix_state WHERE team_id=? AND session_id=?',[args.teamId,member.id]);
|
|
43
|
+
run('DELETE FROM capture_prefix_state WHERE team_id=? AND session_id=?',[args.teamId,member.id]);
|
|
44
|
+
run('DELETE FROM capture_prefix_warm_queue WHERE team_id=? AND session_id=?',[args.teamId,member.id]);
|
|
45
|
+
}
|
|
38
46
|
run('UPDATE teams SET journal_revision=?,auth_epoch=auth_epoch+1 WHERE id=?',[revision,args.teamId]);run('INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)',[args.teamId,revision,canonical.id,'delete',now]);run('INSERT INTO audit(team_id,id,actor_id,operation,target_id,outcome,revision,created_at) VALUES (?,?,?,?,?,?,?,?)',[args.teamId,args.auditId,args.principalId,args.operation,args.correlationId,'denied',revision,now]);return{tombstoned:true,revision,canonicalId:canonical.id};
|
|
39
47
|
}
|
|
40
48
|
const viewBySource=new Map();for(const row of members)for(const version of all('SELECT * FROM session_versions WHERE team_id=? AND session_id=? ORDER BY revision',[args.teamId,row.id])){const sourceId=JSON.parse(version.source_projection_json).source_id??'';if(sourceId&&(!viewBySource.has(sourceId)||version.revision>viewBySource.get(sourceId).revision))viewBySource.set(sourceId,{...row,...version,source_id:sourceId});}const views=[...viewBySource.values()];if(!views.length)fail('constituent projection missing','conflict',409);
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
3
|
+
import { canonicalJson } from '@volter/teams/server/wire';
|
|
4
|
+
import { MESSAGE_AT } from '@volter/teams/server/migrations';
|
|
5
|
+
|
|
6
|
+
// This is a versioned, internal continuation of the exactly pinned noble 2.4.0
|
|
7
|
+
// SHA-256 engine, not a wire digest change. Its get/set state and HashMD metadata
|
|
8
|
+
// are stored together; a digest closes a clone with ']', leaving the state resumable.
|
|
9
|
+
const FORMAT = 'sha256-noble-2.4.0-v1';
|
|
10
|
+
const encoder = new TextEncoder();
|
|
11
|
+
const CHECKPOINT_MESSAGES = 64, CHECKPOINT_BYTES = 64 * 1024;
|
|
12
|
+
const WARM_MESSAGES = 256, WARM_BYTES = 1024 * 1024, GARBAGE_ROWS = 512;
|
|
13
|
+
const error = (status, code, message) => Object.assign(new Error(message), { status, code });
|
|
14
|
+
export function startPrefix() { return sha256.create().update(encoder.encode('[')); }
|
|
15
|
+
export function savePrefix(hash) {
|
|
16
|
+
return JSON.stringify({ format: FORMAT, words: hash.get(), length: hash.length, pos: hash.pos, buffer: Buffer.from(hash.buffer.subarray(0, hash.pos)).toString('base64') });
|
|
17
|
+
}
|
|
18
|
+
export function loadPrefix(state) {
|
|
19
|
+
const value = JSON.parse(state);
|
|
20
|
+
if (value.format !== FORMAT || !Array.isArray(value.words) || value.words.length !== 8 || value.words.some(x => !Number.isInteger(x) || x < -2147483648 || x > 2147483647) || !Number.isSafeInteger(value.length) || value.length < 1 || !Number.isInteger(value.pos) || value.pos < 0 || value.pos >= 64 || value.pos !== value.length % 64 || typeof value.buffer !== 'string')
|
|
21
|
+
throw error(503, 'carry_checkpoint_unavailable', 'capture prefix checkpoint has an unsupported format');
|
|
22
|
+
const bytes = Buffer.from(value.buffer, 'base64');
|
|
23
|
+
if (bytes.length !== value.pos) throw error(503, 'carry_checkpoint_unavailable', 'capture prefix checkpoint has an invalid buffer');
|
|
24
|
+
const hash = sha256.create(); hash.set(...value.words); hash.length = value.length; hash.pos = value.pos; hash.buffer.set(bytes); return hash;
|
|
25
|
+
}
|
|
26
|
+
export function prefixDigest(hash) { return Buffer.from(hash.clone().update(encoder.encode(']')).digest()).toString('hex'); }
|
|
27
|
+
export function addPrefixMessage(hash, count, message) {
|
|
28
|
+
if (count) hash.update(encoder.encode(','));
|
|
29
|
+
hash.update(encoder.encode(canonicalJson({ native_key: message.native_key, recorded_at: message.recorded_at ?? null, value: message.value })));
|
|
30
|
+
}
|
|
31
|
+
const identity = row => [row.team_id, row.session_id, row.source_id, row.generation];
|
|
32
|
+
export function writeCheckpoint(run, state, hash, count) {
|
|
33
|
+
run('INSERT OR REPLACE INTO capture_prefix_checkpoints(team_id,session_id,source_id,generation,count,digest,sha_state) VALUES (?,?,?,?,?,?,?)', [...identity(state), count, prefixDigest(hash), savePrefix(hash)]);
|
|
34
|
+
}
|
|
35
|
+
export function shouldCheckpoint(state, hash, count) { return count - state.checkpoint_count >= CHECKPOINT_MESSAGES || hash.length - state.checkpoint_bytes >= CHECKPOINT_BYTES; }
|
|
36
|
+
|
|
37
|
+
/** Retire by generation: changing a session does not delete its whole prefix ledger on the request loop. */
|
|
38
|
+
export function retirePrefix({ get, run }, teamId, sessionId, sourceId) {
|
|
39
|
+
const state = get('SELECT generation FROM capture_prefix_state WHERE team_id=? AND session_id=? AND source_id=?', [teamId, sessionId, sourceId]);
|
|
40
|
+
if (!state) return;
|
|
41
|
+
run('INSERT OR IGNORE INTO capture_prefix_garbage VALUES (?,?,?,?)', [teamId, sessionId, sourceId, state.generation]);
|
|
42
|
+
run('DELETE FROM capture_prefix_state WHERE team_id=? AND session_id=? AND source_id=?', [teamId, sessionId, sourceId]);
|
|
43
|
+
run('DELETE FROM capture_prefix_warm_queue WHERE team_id=? AND session_id=? AND source_id=?', [teamId, sessionId, sourceId]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Verify a prepared prefix with at most 63 message reads / one checkpoint byte interval. Never a full scan. */
|
|
47
|
+
export function resolveCapturePrefix({ get, run }, { teamId, sessionId, sourceId, revision, count, digest, sentKeys }) {
|
|
48
|
+
let state = get('SELECT * FROM capture_prefix_state WHERE team_id=? AND session_id=? AND source_id=?', [teamId, sessionId, sourceId]);
|
|
49
|
+
if (!state || state.revision !== revision) {
|
|
50
|
+
retirePrefix({ get, run }, teamId, sessionId, sourceId);
|
|
51
|
+
const hash = startPrefix();
|
|
52
|
+
state = { team_id: teamId, session_id: sessionId, source_id: sourceId, revision, generation: randomUUID(), count: 0, target_count: count, sha_state: savePrefix(hash), checkpoint_count: 0, checkpoint_bytes: hash.length, invalid: 0 };
|
|
53
|
+
run('INSERT INTO capture_prefix_state VALUES (?,?,?,?,?,?,?,?,?,?,?)', [teamId, sessionId, sourceId, revision, state.generation, 0, count, state.sha_state, 0, hash.length, 0]);
|
|
54
|
+
writeCheckpoint(run, state, hash, 0);
|
|
55
|
+
}
|
|
56
|
+
if (state.invalid) throw error(409, 'carry_mismatch', 'the carried messages are not what this session holds; send the capture whole');
|
|
57
|
+
if (state.count < count) {
|
|
58
|
+
run('UPDATE capture_prefix_state SET target_count=MAX(target_count,?) WHERE team_id=? AND session_id=? AND source_id=?', [count, teamId, sessionId, sourceId]);
|
|
59
|
+
run('INSERT OR IGNORE INTO capture_prefix_warm_queue(team_id,session_id,source_id) VALUES (?,?,?)', [teamId, sessionId, sourceId]);
|
|
60
|
+
return { pending: true, prepared: state.count, requested: count };
|
|
61
|
+
}
|
|
62
|
+
const checkpoint = get('SELECT * FROM capture_prefix_checkpoints WHERE team_id=? AND session_id=? AND source_id=? AND generation=? AND count<=? ORDER BY count DESC LIMIT 1', [...identity(state), count]);
|
|
63
|
+
if (!checkpoint || count - checkpoint.count >= CHECKPOINT_MESSAGES) throw error(503, 'carry_checkpoint_unavailable', 'capture prefix checkpoint is incomplete');
|
|
64
|
+
const hash = loadPrefix(checkpoint.sha_state);
|
|
65
|
+
for (let ordinal = checkpoint.count; ordinal < count; ordinal++) {
|
|
66
|
+
const row = get(`SELECT ordinal,source_id,native_key,recorded_at,value_json FROM messages m WHERE m.team_id=? AND m.session_id=? AND m.ordinal=? AND ${MESSAGE_AT()} LIMIT 1`, [teamId, sessionId, ordinal, revision, revision]);
|
|
67
|
+
if (!row || row.source_id !== sourceId) throw error(409, 'carry_mismatch', 'the carried messages are not what this session holds; send the capture whole');
|
|
68
|
+
addPrefixMessage(hash, ordinal, { ...row, value: JSON.parse(row.value_json) });
|
|
69
|
+
}
|
|
70
|
+
if (prefixDigest(hash) !== digest) throw error(409, 'carry_mismatch', 'the carried messages are not what this session holds; send the capture whole');
|
|
71
|
+
for (const key of sentKeys) {
|
|
72
|
+
const held = get('SELECT ordinal FROM capture_prefix_keys WHERE team_id=? AND session_id=? AND source_id=? AND generation=? AND native_key=?', [...identity(state), key]);
|
|
73
|
+
if (held && held.ordinal < count) throw error(409, 'capture_conflict', 'duplicate native message key');
|
|
74
|
+
}
|
|
75
|
+
return { pending: false, generation: state.generation, sha_state: savePrefix(hash), checkpoint_count: state.checkpoint_count <= count ? state.checkpoint_count : checkpoint.count, checkpoint_bytes: state.checkpoint_count <= count ? state.checkpoint_bytes : loadPrefix(checkpoint.sha_state).length };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Stop starting messages at the row/byte bound, independent of a frozen clock.
|
|
79
|
+
* A single message may cross the byte bound; its size is bounded by capture admission. */
|
|
80
|
+
export function warmCapturePrefixes({ get, run }) {
|
|
81
|
+
let messages = 0, bytes = 0;
|
|
82
|
+
const garbage = get('SELECT * FROM capture_prefix_garbage ORDER BY rowid LIMIT 1');
|
|
83
|
+
if (garbage) {
|
|
84
|
+
for (const table of ['capture_prefix_keys', 'capture_prefix_checkpoints']) run(`DELETE FROM ${table} WHERE rowid IN (SELECT rowid FROM ${table} WHERE team_id=? AND session_id=? AND source_id=? AND generation=? LIMIT ${GARBAGE_ROWS})`, identity(garbage));
|
|
85
|
+
if (!get('SELECT 1 FROM capture_prefix_keys WHERE team_id=? AND session_id=? AND source_id=? AND generation=? LIMIT 1', identity(garbage)) && !get('SELECT 1 FROM capture_prefix_checkpoints WHERE team_id=? AND session_id=? AND source_id=? AND generation=? LIMIT 1', identity(garbage))) run('DELETE FROM capture_prefix_garbage WHERE team_id=? AND session_id=? AND source_id=? AND generation=?', identity(garbage));
|
|
86
|
+
}
|
|
87
|
+
const queued = get('SELECT * FROM capture_prefix_warm_queue ORDER BY ticket LIMIT 1');
|
|
88
|
+
const state = queued ? get('SELECT * FROM capture_prefix_state WHERE team_id=? AND session_id=? AND source_id=?', [queued.team_id, queued.session_id, queued.source_id]) : null;
|
|
89
|
+
if (queued) run('DELETE FROM capture_prefix_warm_queue WHERE ticket=?', [queued.ticket]);
|
|
90
|
+
if (state) {
|
|
91
|
+
const session = get('SELECT s.revision,s.deleted_at,src.state source_state FROM sessions s JOIN sources src ON src.team_id=s.team_id AND src.id=? WHERE s.team_id=? AND s.id=?', [state.source_id, state.team_id, state.session_id]);
|
|
92
|
+
if (!session || session.source_state !== 'active' || session.deleted_at != null || session.revision !== state.revision) retirePrefix({ get, run }, state.team_id, state.session_id, state.source_id);
|
|
93
|
+
else {
|
|
94
|
+
const hash = loadPrefix(state.sha_state), startBytes = hash.length;
|
|
95
|
+
if (state.count !== state.checkpoint_count) run('DELETE FROM capture_prefix_checkpoints WHERE team_id=? AND session_id=? AND source_id=? AND generation=? AND count=?', [...identity(state), state.count]);
|
|
96
|
+
while (state.count < state.target_count && messages < WARM_MESSAGES && hash.length - startBytes < WARM_BYTES) {
|
|
97
|
+
const row = get(`SELECT ordinal,source_id,native_key,recorded_at,value_json FROM messages m WHERE m.team_id=? AND m.session_id=? AND m.ordinal=? AND ${MESSAGE_AT()} LIMIT 1`, [state.team_id, state.session_id, state.count, state.revision, state.revision]);
|
|
98
|
+
if (!row || row.source_id !== state.source_id || get('SELECT 1 FROM capture_prefix_keys WHERE team_id=? AND session_id=? AND source_id=? AND generation=? AND native_key=?', [...identity(state), row?.native_key ?? ''])) { state.invalid = 1; break; }
|
|
99
|
+
addPrefixMessage(hash, state.count, { ...row, value: JSON.parse(row.value_json) });
|
|
100
|
+
run('INSERT INTO capture_prefix_keys VALUES (?,?,?,?,?,?)', [...identity(state), row.native_key, state.count]);
|
|
101
|
+
state.count++; messages++;
|
|
102
|
+
if (shouldCheckpoint(state, hash, state.count)) { writeCheckpoint(run, state, hash, state.count); state.checkpoint_count = state.count; state.checkpoint_bytes = hash.length; }
|
|
103
|
+
}
|
|
104
|
+
// Keep the exact last state too; it lets a full-prefix append avoid every body read.
|
|
105
|
+
if (state.count === state.target_count) writeCheckpoint(run, state, hash, state.count);
|
|
106
|
+
run('UPDATE capture_prefix_state SET count=?,sha_state=?,checkpoint_count=?,checkpoint_bytes=?,invalid=? WHERE team_id=? AND session_id=? AND source_id=? AND generation=?', [state.count, savePrefix(hash), state.checkpoint_count, state.checkpoint_bytes, state.invalid, ...identity(state)]);
|
|
107
|
+
bytes = hash.length - startBytes;
|
|
108
|
+
// FIFO across sessions, including ones whose warming began after this one.
|
|
109
|
+
if (!state.invalid && state.count < state.target_count) run('INSERT OR IGNORE INTO capture_prefix_warm_queue(team_id,session_id,source_id) VALUES (?,?,?)', [state.team_id, state.session_id, state.source_id]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { messages, bytes, backlog: !!get('SELECT 1 FROM capture_prefix_warm_queue LIMIT 1') || !!get('SELECT 1 FROM capture_prefix_garbage LIMIT 1') };
|
|
113
|
+
}
|
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { MESSAGE_AT, OPEN_REVISION } from '@volter/teams/server/migrations';
|
|
3
3
|
import { normalizeSearch, textFromContent } from './catalog.mjs';
|
|
4
|
+
import { addPrefixMessage, loadPrefix, retirePrefix, savePrefix, shouldCheckpoint, writeCheckpoint } from './capture-prefixes.mjs';
|
|
4
5
|
|
|
5
6
|
export function applyCaptureMessages({ get, run }, capture, revision) {
|
|
6
|
-
const { teamId, sessionId, sourceId, captureId, harness, nativeId, currentRevision, carriedCount, parts, messageNamespace } = capture;
|
|
7
|
+
const { teamId, sessionId, sourceId, captureId, harness, nativeId, currentRevision, carriedCount, parts, messageNamespace, prefix } = capture;
|
|
8
|
+
let prefixState = null, prefixHash = null;
|
|
9
|
+
if (prefix) {
|
|
10
|
+
prefixState = get('SELECT * FROM capture_prefix_state WHERE team_id=? AND session_id=? AND source_id=?', [teamId, sessionId, sourceId]);
|
|
11
|
+
if (!prefixState || prefixState.revision !== currentRevision || prefixState.generation !== prefix.generation) throw Object.assign(new Error('session prefix changed before commit; retry'), { status: 409, code: 'revision_mismatch' });
|
|
12
|
+
prefixHash = loadPrefix(prefix.sha_state);
|
|
13
|
+
if (prefixState.count !== prefixState.checkpoint_count) run('DELETE FROM capture_prefix_checkpoints WHERE team_id=? AND session_id=? AND source_id=? AND generation=? AND count=?', [teamId, sessionId, sourceId, prefix.generation, prefixState.count]);
|
|
14
|
+
prefixState.checkpoint_count = prefix.checkpoint_count;
|
|
15
|
+
prefixState.checkpoint_bytes = prefix.checkpoint_bytes;
|
|
16
|
+
// Remove only a tail the capture replaces, through its ordinal/count index.
|
|
17
|
+
run('DELETE FROM capture_prefix_keys WHERE team_id=? AND session_id=? AND source_id=? AND generation=? AND ordinal>=?', [teamId, sessionId, sourceId, prefix.generation, carriedCount]);
|
|
18
|
+
run('DELETE FROM capture_prefix_checkpoints WHERE team_id=? AND session_id=? AND source_id=? AND generation=? AND count>?', [teamId, sessionId, sourceId, prefix.generation, carriedCount]);
|
|
19
|
+
} else retirePrefix({ get, run }, teamId, sessionId, sourceId);
|
|
7
20
|
let ordinal = carriedCount;
|
|
8
21
|
for (let index = 0; index < parts; index++) {
|
|
9
22
|
const heldPart = get('SELECT payload_json FROM capture_parts WHERE team_id=? AND capture_id=? AND part_index=?', [teamId, captureId, index]);
|
|
10
23
|
if (!heldPart) throw Object.assign(new Error('capture parts incomplete'), { status: 409, code: 'batch_gap' });
|
|
11
24
|
const part = JSON.parse(heldPart.payload_json);
|
|
12
25
|
for (const message of part.messages) {
|
|
26
|
+
if (prefixHash) {
|
|
27
|
+
addPrefixMessage(prefixHash, ordinal, message);
|
|
28
|
+
run('INSERT INTO capture_prefix_keys VALUES (?,?,?,?,?,?) ON CONFLICT(team_id,session_id,source_id,generation,native_key) DO UPDATE SET ordinal=excluded.ordinal', [teamId, sessionId, sourceId, prefix.generation, message.native_key, ordinal]);
|
|
29
|
+
if (shouldCheckpoint(prefixState, prefixHash, ordinal + 1)) {
|
|
30
|
+
writeCheckpoint(run, prefixState, prefixHash, ordinal + 1);
|
|
31
|
+
prefixState.checkpoint_count = ordinal + 1; prefixState.checkpoint_bytes = prefixHash.length;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
13
34
|
const value = JSON.stringify(message.value);
|
|
14
35
|
const old = currentRevision == null ? null : get(`SELECT revision,source_id,native_key,recorded_at,value_json,until_revision FROM messages m WHERE m.team_id=? AND m.session_id=? AND m.ordinal=? AND ${MESSAGE_AT()} LIMIT 1`, [teamId, sessionId, ordinal, currentRevision, currentRevision]);
|
|
15
36
|
if (old && old.source_id === sourceId && old.native_key === message.native_key && old.recorded_at === message.recorded_at && old.value_json === value) {
|
|
@@ -27,5 +48,10 @@ export function applyCaptureMessages({ get, run }, capture, revision) {
|
|
|
27
48
|
}
|
|
28
49
|
}
|
|
29
50
|
if (currentRevision != null) run('UPDATE messages SET until_revision=? WHERE team_id=? AND session_id=? AND ordinal>=? AND revision<=? AND until_revision=?', [revision, teamId, sessionId, ordinal, currentRevision, OPEN_REVISION]);
|
|
51
|
+
if (prefixHash) {
|
|
52
|
+
writeCheckpoint(run, prefixState, prefixHash, ordinal);
|
|
53
|
+
run('UPDATE capture_prefix_state SET revision=?,count=?,target_count=?,sha_state=?,checkpoint_count=?,checkpoint_bytes=? WHERE team_id=? AND session_id=? AND source_id=? AND generation=?', [revision, ordinal, ordinal, savePrefix(prefixHash), prefixState.checkpoint_count, prefixState.checkpoint_bytes, teamId, sessionId, sourceId, prefix.generation]);
|
|
54
|
+
run('DELETE FROM capture_prefix_warm_queue WHERE team_id=? AND session_id=? AND source_id=?', [teamId, sessionId, sourceId]);
|
|
55
|
+
}
|
|
30
56
|
return { messages: ordinal - carriedCount };
|
|
31
57
|
}
|
|
@@ -245,13 +245,14 @@ async function correlateRoute(api,req,res,auth,path,requestId){
|
|
|
245
245
|
if(body.evidence!=='custodian_attestation'||typeof body.harness!=='string'||typeof body.left_source_id!=='string'||typeof body.right_source_id!=='string'||typeof body.left_native_id!=='string'||typeof body.right_native_id!=='string'||!Number.isSafeInteger(body.left_revision)||!Number.isSafeInteger(body.right_revision))fail(400,'invalid_request','invalid correlation attestation');
|
|
246
246
|
const correlationId=api.ids.next('correlation'),record=res._idempotencyRecord;
|
|
247
247
|
const outcome=await api.store.correlateSessions({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,now:api.clock.nowMs(),correlationId,auditId:api.ids.next('audit'),operation:`POST ${path}`,context:{server_id:api.serverId,team_id:auth.team_id,principal_id:auth.principal_id},idempotency:record?{route:record.route,key:record.key,requestDigest:record.requestDigest}:null,harness:body.harness,evidence:body,left:{sourceId:body.left_source_id,nativeId:body.left_native_id,revision:body.left_revision},right:{sourceId:body.right_source_id,nativeId:body.right_native_id,revision:body.right_revision}});
|
|
248
|
-
if(outcome.tombstoned){res._sideEffectsCommitted=true;api.events.publish(auth.team_id,{type:'session_deleted',cursor:String(outcome.revision),revision:outcome.revision,session_id:outcome.canonicalId});fail(410,'session_deleted','a correlated constituent was deleted');}
|
|
248
|
+
if(outcome.tombstoned){res._sideEffectsCommitted=true;api.events.publish(auth.team_id,{type:'session_deleted',cursor:String(outcome.revision),revision:outcome.revision,session_id:outcome.canonicalId});await api.scheduleCapturePrefixWork?.();fail(410,'session_deleted','a correlated constituent was deleted');}
|
|
249
249
|
res._sideEffectsCommitted=true;api.events.publish(auth.team_id,{type:'session_changed',cursor:String(outcome.revision),revision:outcome.revision,session_id:outcome.canonicalId,reason:'metadata',read_required:true});send(res,200,outcome.response,requestId,{ETag:`"${outcome.revision}"`});
|
|
250
250
|
}
|
|
251
251
|
async function mutateSession(api,req,res,auth,id,requestId){const seed=await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=?',[auth.team_id,id]),root=await canonicalSession(api,auth.team_id,seed),canonicalId=root?.id??id;const row=await api.store.one('SELECT s.*,v.descriptor_json,v.source_projection_json,v.search_title,v.sort_at FROM sessions s JOIN session_versions v ON v.team_id=s.team_id AND v.session_id=s.id AND v.revision=s.revision WHERE s.team_id=? AND s.id=?',[auth.team_id,canonicalId]);if(!row)fail(404,'not_found','not found');const members=await constituents(api,auth.team_id,row);for(const member of members)if(!isAdmin(auth)&&!canManage(auth,member,await grants(api,auth.team_id,member.id)))fail(404,'not_found','not found');const expected=Number(String(req.headers['if-match']??'').replaceAll('"',''));if(!Number.isSafeInteger(expected))fail(400,'invalid_request','If-Match is required');if(expected!==row.revision)fail(409,'revision_mismatch','session revision changed');const boundaryNow=api.clock.nowMs(),revisionParam={$journalRevision:true},guards=[...authGuards(auth,boundaryNow),{sql:'SELECT 1 ok FROM sessions WHERE team_id=? AND id=? AND revision=? AND deleted_at IS NULL',params:[auth.team_id,row.id,expected],code:'revision_mismatch',status:409,message:'session revision changed'}];
|
|
252
252
|
for(const member of members)guards.push(managerGuard(auth,member.id,boundaryNow,{governance:true}));
|
|
253
|
-
if(req.method==='DELETE'){const deletion=[];for(const member of members)deletion.push({sql:'UPDATE sessions SET deleted_at=?,revision=? WHERE team_id=? AND id=?',params:[boundaryNow,revisionParam,auth.team_id,member.id]},{sql:'UPDATE origins SET deleted_at=? WHERE team_id=? AND session_id=?',params:[boundaryNow,auth.team_id,member.id]},{sql:'DELETE FROM session_versions WHERE team_id=? AND session_id=?',params:[auth.team_id,member.id]});deletion.push({sql:'UPDATE teams SET journal_revision=?,auth_epoch=auth_epoch+1 WHERE id=?',params:[revisionParam,auth.team_id]},{sql:'INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)',params:[auth.team_id,revisionParam,row.id,'delete',boundaryNow]});const {revision}=await guardedMutation(api,res,auth,guards,deletion,{status:204,body:null,targetId:row.id,journal:true});api.events.publish(auth.team_id,{type:'session_deleted',cursor:String(revision),revision,session_id:row.id});return send(res,204,null,requestId);}
|
|
253
|
+
if(req.method==='DELETE'){const deletion=[];for(const member of members)deletion.push({sql:'UPDATE sessions SET deleted_at=?,revision=? WHERE team_id=? AND id=?',params:[boundaryNow,revisionParam,auth.team_id,member.id]},{sql:'UPDATE origins SET deleted_at=? WHERE team_id=? AND session_id=?',params:[boundaryNow,auth.team_id,member.id]},{sql:'DELETE FROM session_versions WHERE team_id=? AND session_id=?',params:[auth.team_id,member.id]},{sql:'INSERT OR IGNORE INTO capture_prefix_garbage SELECT team_id,session_id,source_id,generation FROM capture_prefix_state WHERE team_id=? AND session_id=?',params:[auth.team_id,member.id]},{sql:'DELETE FROM capture_prefix_state WHERE team_id=? AND session_id=?',params:[auth.team_id,member.id]},{sql:'DELETE FROM capture_prefix_warm_queue WHERE team_id=? AND session_id=?',params:[auth.team_id,member.id]});deletion.push({sql:'UPDATE teams SET journal_revision=?,auth_epoch=auth_epoch+1 WHERE id=?',params:[revisionParam,auth.team_id]},{sql:'INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)',params:[auth.team_id,revisionParam,row.id,'delete',boundaryNow]});const {revision}=await guardedMutation(api,res,auth,guards,deletion,{status:204,body:null,targetId:row.id,journal:true});api.events.publish(auth.team_id,{type:'session_deleted',cursor:String(revision),revision,session_id:row.id});await api.scheduleCapturePrefixWork?.();return send(res,204,null,requestId);}
|
|
254
254
|
const body=await parseJson(req);exact(body,['title']);if(body.title!==null&&typeof body.title!=='string')fail(400,'invalid_request','title must be string or null');const descriptor={...projection(row),title:body.title};delete descriptor.revision;const statements=[{sql:'UPDATE sessions SET revision=? WHERE team_id=? AND id=? AND revision=?',params:[revisionParam,auth.team_id,row.id,expected]},{sql:'INSERT INTO session_versions(team_id,session_id,revision,descriptor_json,source_projection_json,search_title,sort_at,created_at) VALUES (?,?,?,?,?,?,?,?)',params:[auth.team_id,row.id,revisionParam,{$journalJson:descriptor},row.source_projection_json,normalizeSearch(body.title??''),row.sort_at,boundaryNow]}];// The new version holds the same messages: the current version's rows stay open into it (schema 9), none copied.
|
|
255
|
+
statements.push({sql:'UPDATE capture_prefix_state SET revision=? WHERE team_id=? AND session_id=? AND revision=?',params:[revisionParam,auth.team_id,row.id,expected]});
|
|
255
256
|
statements.push({sql:`UPDATE messages SET until_revision=? WHERE team_id=? AND session_id=? AND ${MESSAGE_AT('messages')}`,params:[OPEN_REVISION,auth.team_id,row.id,row.revision,row.revision]});statements.push({sql:'UPDATE teams SET journal_revision=? WHERE id=?',params:[revisionParam,auth.team_id]},{sql:'INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)',params:[auth.team_id,revisionParam,row.id,'metadata',boundaryNow]});const storedResponse=result(api,auth,{...descriptor,revision:'__JOURNAL_REVISION__'}),{revision}=await guardedMutation(api,res,auth,guards,statements,{status:200,body:storedResponse,targetId:row.id,journal:true}),responseDescriptor={...descriptor,revision};api.events.publish(auth.team_id,{type:'session_changed',cursor:String(revision),revision,session_id:row.id,reason:'metadata',read_required:true});send(res,200,result(api,auth,responseDescriptor),requestId,{ETag:`"${revision}"`});}
|
|
256
257
|
async function readSession(api,res,auth,id,url,requestId){
|
|
257
258
|
const query=qobj(url);
|
|
@@ -414,30 +415,21 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
|
|
|
414
415
|
}
|
|
415
416
|
if(commit.native_bundle!=null){const bundle=commit.native_bundle;exact(bundle,['id','harness','fidelity','residue','files'],'native_bundle');if(typeof bundle.id!=='string'||bundle.harness!==cap.harness||!['byte_lossless','value_lossless','semantic'].includes(bundle.fidelity)||!Array.isArray(bundle.residue)||!bundle.residue.every(x=>typeof x==='string')||!Array.isArray(bundle.files))fail(400,'invalid_request','invalid native bundle');const artifactIds=new Set(commit.artifacts.map(x=>x.upload_id)),seen=new Set();for(const file of bundle.files){exact(file,['upload_id','role'],'native_bundle file');if(!artifactIds.has(file.upload_id)||seen.has(file.upload_id)||!['primary','subagent','bundle','source_recovery'].includes(file.role))fail(400,'invalid_request','native bundle files must uniquely reference captured artifacts');seen.add(file.upload_id);}if(seen.size!==artifactIds.size||bundle.files.filter(x=>x.role==='primary').length!==1||new Set(commit.artifacts.map(x=>x.relative_name)).size!==commit.artifacts.length)fail(400,'invalid_request','native bundle must describe every unique artifact and one primary');}
|
|
416
417
|
let origin=await api.store.one('SELECT * FROM origins WHERE team_id=? AND source_id=? AND harness=? AND native_id=?',[auth.team_id,sourceId,cap.harness,cap.native_id]);
|
|
417
|
-
//
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
let carriedCount = 0, carriedRevision = null;
|
|
418
|
+
// Prepared digests are owned by this source and fenced by the session revision.
|
|
419
|
+
// A cold prefix queues bounded background work; no request falls back to a whole transcript read.
|
|
420
|
+
let carriedCount = 0, carriedRevision = null, prefix = null;
|
|
421
421
|
if (begin.carry) {
|
|
422
422
|
const { count, digest } = begin.carry;
|
|
423
423
|
const session = origin ? await api.store.one('SELECT revision FROM sessions WHERE team_id=? AND id=?', [auth.team_id, origin.session_id]) : null;
|
|
424
424
|
if (!session || count + messageCount > 100000) fail(409, 'carry_mismatch', 'the carried prefix is unavailable or too large');
|
|
425
425
|
carriedRevision = session.revision;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
if (!page.length) fail(409, 'carry_mismatch', 'the carried messages are not what this session holds; send the capture whole');
|
|
432
|
-
for (const row of page) {
|
|
433
|
-
if (row.ordinal !== carriedCount || row.source_id !== sourceId) fail(409, 'carry_mismatch', 'the carried messages are not what this session holds; send the capture whole');
|
|
434
|
-
if (sentKeys.has(row.native_key)) fail(409, 'capture_conflict', 'duplicate native message key');
|
|
435
|
-
if (carriedCount) hash.update(',');
|
|
436
|
-
hash.update(canonicalJson({native_key:row.native_key, recorded_at:row.recorded_at ?? null, value:JSON.parse(row.value_json)}));
|
|
437
|
-
carriedCount++;
|
|
438
|
-
}
|
|
426
|
+
prefix = await api.store.call('resolveCapturePrefix', { teamId: auth.team_id, sessionId: origin.session_id, sourceId, revision: carriedRevision, count, digest, sentKeys: [...sentKeys] });
|
|
427
|
+
if (prefix.pending) {
|
|
428
|
+
await api.scheduleCapturePrefixWork?.();
|
|
429
|
+
res.setHeader('Retry-After', '1');
|
|
430
|
+
fail(503, 'carry_checkpoint_pending', 'the session prefix is preparing; retry this capture without resending its messages');
|
|
439
431
|
}
|
|
440
|
-
|
|
432
|
+
carriedCount = count;
|
|
441
433
|
}
|
|
442
434
|
let lineage=null,lineageValue=null,lineageMembers=[],lineageGuards=[];if(!origin&&source.kind==='connector'&&source.machine_id){const candidates=await api.store.query("SELECT * FROM continuations WHERE team_id=? AND state='running' AND json_extract(value_json,'$.new_session_id') IS NULL AND json_extract(value_json,'$.harness')=? AND json_extract(value_json,'$.destination.machine_id')=? AND json_extract(value_json,'$.remote_result.state')='succeeded' AND json_extract(value_json,'$.remote_result.receipt.native_session_id')=?",[auth.team_id,cap.harness,source.machine_id,cap.native_id]);if(candidates.length===1){const candidate=candidates[0],membership=await api.store.one('SELECT role,active FROM memberships WHERE team_id=? AND principal_id=?',[auth.team_id,candidate.requester_id]),reader={kind:'user',team_id:auth.team_id,principal_id:candidate.requester_id,role:membership?.role,active:membership?.active===1,now:api.clock.nowMs(),group_ids:(await api.store.query('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[auth.team_id,candidate.requester_id])).map(x=>x.group_id)};if(await canReadMerged(api,reader,await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=?',[auth.team_id,candidate.source_session_id]))){lineage=candidate;lineageValue=JSON.parse(candidate.value_json);lineageMembers=await constituents(api,auth.team_id,await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=?',[auth.team_id,candidate.source_session_id]));}}if(lineageValue){const lineageNow=api.clock.nowMs();lineageGuards=[{sql:'SELECT 1 ok FROM memberships WHERE team_id=? AND principal_id=? AND active=1',params:[auth.team_id,lineage.requester_id],code:'conflict',status:409,message:'continuation requester membership changed'},{sql:'SELECT 1 ok FROM credentials WHERE id=? AND principal_id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',params:[lineageValue.issuer_credential_id,lineage.requester_id,lineageNow],code:'conflict',status:409,message:'continuation issuer credential changed'},...lineageMembers.map(member=>({sql:`SELECT 1 ok FROM sessions s WHERE s.team_id=? AND s.id=? AND s.deleted_at IS NULL AND (?=0 OR s.access_revision=?) AND (s.visibility='team' OR s.custodian_id=? OR EXISTS (SELECT 1 FROM session_grants g WHERE g.team_id=s.team_id AND g.session_id=s.id AND (g.expires_at IS NULL OR g.expires_at>?) AND (g.subject_kind='principal' AND g.subject_id=? OR g.subject_kind='group' AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.team_id=s.team_id AND gm.group_id=g.subject_id AND gm.principal_id=?))))`,params:[auth.team_id,member.id,member.id===lineage.source_session_id?1:0,lineageValue.access_revision,lineage.requester_id,lineageNow,lineage.requester_id,lineage.requester_id],code:'conflict',status:409,message:'continuation source authorization changed'}))];}}
|
|
443
435
|
const sid=origin?.session_id??api.ids.next('session'),parentOrigin=commit.parent_native_id?await api.store.one('SELECT session_id FROM origins WHERE team_id=? AND source_id=? AND harness=? AND native_id=? AND deleted_at IS NULL',[auth.team_id,sourceId,cap.harness,commit.parent_native_id]):null,revisionParam={$journalRevision:true},recorded=commit.recorded_activity_at,boundaryNow=api.clock.nowMs(),sortAt=recorded??boundaryNow,descriptor={id:sid,revision:'__JOURNAL_REVISION__',title:commit.title,harness:cap.harness,user:{principal_id:source.kind==='connector'?source.custodian_id:null,reported_user:commit.reported_user,status:source.kind==='connector'?'verified':commit.reported_user?'reported':'unknown',mapping_id:null},repository:commit.repository,parent_id:parentOrigin?.session_id??null,continued_from:lineage?.source_session_id??null,recorded_activity_at:recorded,received_at:boundaryNow,sort_at:sortAt,activity_basis:recorded==null?'received':'recorded',source_ids:[sourceId],machine_ids:source.machine_id?[source.machine_id]:[],coverage:commit.coverage,bindings:[],actions:{}},statements=[];
|
|
@@ -452,7 +444,7 @@ async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){cons
|
|
|
452
444
|
carryGuards.push({ sql: 'SELECT 1 ok WHERE (SELECT COUNT(*) FROM capture_parts WHERE team_id=? AND capture_id=?)=?', params: [auth.team_id, captureId, commit.parts], status: 409, code: 'batch_gap', message: 'capture parts changed before commit' });
|
|
453
445
|
// Another commit to this session between this read and the write would make the carried rows wrong: refused, retried.
|
|
454
446
|
if(currentRevision!=null)carryGuards.push({sql:'SELECT 1 ok FROM sessions WHERE team_id=? AND id=? AND revision=?',params:[auth.team_id,sid,currentRevision],code:'revision_mismatch',status:409,message:'session changed during capture; retry'});
|
|
455
|
-
statements.push({ kind: 'capture_messages', capture: { teamId: auth.team_id, sessionId: sid, sourceId, captureId, harness: cap.harness, nativeId: cap.native_id, currentRevision, carriedCount, parts: commit.parts, messageNamespace: api.ids.next('message') } });
|
|
447
|
+
statements.push({ kind: 'capture_messages', capture: { teamId: auth.team_id, sessionId: sid, sourceId, captureId, harness: cap.harness, nativeId: cap.native_id, currentRevision, carriedCount, prefix, parts: commit.parts, messageNamespace: api.ids.next('message') } });
|
|
456
448
|
const bundle=commit.native_bundle??null,legacyBundle=commit.artifacts.length?captureId:null;for(let index=0;index<commit.artifacts.length;index++){const a=commit.artifacts[index],file=bundle?.files.find(x=>x.upload_id===a.upload_id);statements.push({sql:'UPDATE uploads SET manifest_json=?,committed=1 WHERE team_id=? AND id=?',params:[JSON.stringify(a),auth.team_id,a.upload_id]},{sql:'INSERT INTO session_artifacts(team_id,session_id,revision,bundle_id,upload_id,format,provenance,file_role,fidelity,residue_json) VALUES (?,?,?,?,?,?,?,?,?,?)',params:[auth.team_id,sid,revisionParam,bundle?.id??legacyBundle,a.upload_id,bundle?.harness??cap.harness,'capture',file?.role??(index===0?'primary':'bundle'),bundle?.fidelity??'semantic',JSON.stringify(bundle?.residue??['native_bundle_metadata_unavailable'])]});}const storedReceipt={source_id:sourceId,capture_id:captureId,session_id:sid,generation:cap.generation,sequence:cap.sequence,digest:commit.digest,revision:'__JOURNAL_REVISION__',duplicate:false};statements.push({sql:"UPDATE captures SET state='committed',commit_json=?,receipt_json=? WHERE team_id=? AND id=?",params:[JSON.stringify(commit),{$journalJsonValue:storedReceipt},auth.team_id,captureId]},{sql:'DELETE FROM capture_parts WHERE team_id=? AND capture_id=?',params:[auth.team_id,captureId]},{sql:'UPDATE sources SET last_received_at=? WHERE team_id=? AND id=?',params:[boundaryNow,auth.team_id,sourceId]},{sql:'UPDATE teams SET journal_revision=? WHERE id=?',params:[revisionParam,auth.team_id]},{sql:'INSERT INTO journal(team_id,revision,session_id,kind,created_at) VALUES (?,?,?,?,?)',params:[auth.team_id,revisionParam,sid,'capture',boundaryNow]});const guards=[{sql:'SELECT 1 ok FROM credentials WHERE id=? AND source_id=? AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)',params:[auth.credential_id,sourceId,boundaryNow],code:'authentication_required',status:401,message:'source credential expired or revoked'},...lineageGuards,...carryGuards,{sql:"SELECT 1 ok FROM captures c JOIN sources s ON s.team_id=c.team_id AND s.id=c.source_id LEFT JOIN machines m ON m.team_id=s.team_id AND m.id=s.machine_id LEFT JOIN sync_rules r ON r.team_id=c.team_id AND r.id=json_extract(c.begin_json,'$.rule_id') WHERE c.team_id=? AND c.id=? AND c.source_id=? AND c.state='staging' AND s.state='active' AND (s.kind!='connector' OR (m.revoked_at IS NULL AND r.machine_id=m.id AND r.source_id=s.id AND r.state='active' AND r.revision=json_extract(c.begin_json,'$.rule_revision') AND r.visibility=json_extract(c.begin_json,'$.visibility')))",params:[auth.team_id,captureId,sourceId],code:'conflict',status:409,message:'capture authorization changed'}],storedResponse=result(api,auth,storedReceipt),{revision}=await guardedMutation(api,res,auth,guards,statements,{status:200,body:storedResponse,targetId:captureId,journal:true}),receipt={...storedReceipt,revision};api.events.publish(auth.team_id,{type:'session_changed',cursor:String(revision),revision,session_id:sid,reason:'capture',read_required:true});send(res,200,result(api,auth,receipt),requestId);
|
|
457
449
|
}
|
|
458
450
|
|
package/server/index.mjs
CHANGED
|
@@ -24,7 +24,14 @@ export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},p
|
|
|
24
24
|
// The harness's own routes on this server (harness-routes.mjs); the handler owns the neutral ones.
|
|
25
25
|
api.attach({harnessRoutes});
|
|
26
26
|
const events=api.events;
|
|
27
|
-
let store,http,upgrade,lockFd,maintenanceTimer,maintenanceWork,started=false;
|
|
27
|
+
let store,http,upgrade,lockFd,maintenanceTimer,maintenanceWork,maintenanceRunning=false,prefixRequested=false,maintenanceDue=0,started=false;
|
|
28
|
+
api.scheduleCapturePrefixWork=async()=>{
|
|
29
|
+
prefixRequested=true;
|
|
30
|
+
if(!started||maintenanceRunning)return;
|
|
31
|
+
if(maintenanceTimer)clearTimeout(maintenanceTimer);
|
|
32
|
+
maintenanceTimer=setTimeout(()=>{maintenanceWork=maintenance();},100);maintenanceTimer.unref?.();
|
|
33
|
+
};
|
|
34
|
+
let maintenance;
|
|
28
35
|
api.start=async()=>{
|
|
29
36
|
if(started)throw new Error('Teams server already started'); mkdirSync(dataDir,{recursive:true,mode:0o700});chmodSync(dataDir,0o700);
|
|
30
37
|
const lock=join(dataDir,'server.lock'); const acquire=()=>{lockFd=openSync(lock,'wx',0o600);writeFileSync(lockFd,JSON.stringify({pid:process.pid,started_at:clock.nowMs()}));};try{acquire();}catch(e){let recover=false;try{const owner=JSON.parse(readFileSync(lock,'utf8'));if(Number.isInteger(owner.pid)&&owner.pid>1){try{process.kill(owner.pid,0);}catch(error){recover=error.code==='ESRCH';}}}catch{}if(!recover)throw new Error(`Teams data directory is already owned: ${dataDir}`,{cause:e});unlinkSync(lock);acquire();}
|
|
@@ -43,14 +50,24 @@ export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},p
|
|
|
43
50
|
}
|
|
44
51
|
api.attach({serverId});
|
|
45
52
|
if(existsSync(credentialPath))api.appRootSecret=JSON.parse(readFileSync(credentialPath,'utf8')).token;
|
|
46
|
-
|
|
53
|
+
maintenance=async()=>{
|
|
54
|
+
maintenanceRunning=true;prefixRequested=false;
|
|
47
55
|
let next=60000;
|
|
48
56
|
try{
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
if(
|
|
57
|
+
const prefixes=await store.call('warmCapturePrefixes').catch(error=>{console.error(`Teams capture prefix preparation failed: ${error.message}`);return {backlog:true};});
|
|
58
|
+
// Prefix preparation runs in small batches; it must not pull retention,
|
|
59
|
+
// compaction or filesystem probes forward on every batch.
|
|
60
|
+
if(Date.now()>=maintenanceDue){
|
|
61
|
+
const result=await maintain(api);maintenanceDue=Date.now()+maintainDelay(api,result);
|
|
62
|
+
if(!result?.backlog){const reclaimed=await store.call('reclaimStorage');if(reclaimed.after.allocated<reclaimed.before.allocated)console.log(`Teams storage reclaimed: ${JSON.stringify(reclaimed)}`);}
|
|
63
|
+
const usage=await api.storageUsage();
|
|
64
|
+
if(usage.used>=usage.limit*0.8||usage.available_bytes<usage.min_free_bytes+usage.limit*0.2)console.warn(`Teams storage pressure: ${JSON.stringify(usage)}; native admission stops at90% or the configured disk reserve`);
|
|
65
|
+
}
|
|
66
|
+
next=Math.max(1,maintenanceDue-Date.now());
|
|
67
|
+
if(prefixes.backlog)next=Math.min(next,100);
|
|
53
68
|
}catch(error){console.error(`Teams maintenance failed: ${error.message}`);next=3000;}
|
|
69
|
+
maintenanceRunning=false;
|
|
70
|
+
if(prefixRequested)next=Math.min(next,100);
|
|
54
71
|
if(started){maintenanceTimer=setTimeout(()=>{maintenanceWork=maintenance();},next);maintenanceTimer.unref?.();}
|
|
55
72
|
return next;
|
|
56
73
|
};
|
package/server/store-core.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { ingestOtlpBatch, correlateSessions } from './arc2-transactions.mjs';
|
|
7
7
|
import { claimEffect, getEffect, listEffects, reportEffect } from './effects-transactions.mjs';
|
|
8
8
|
import { applyCaptureMessages } from './capture-transactions.mjs';
|
|
9
|
+
import { resolveCapturePrefix, warmCapturePrefixes } from './capture-prefixes.mjs';
|
|
9
10
|
import { MIGRATIONS, MESSAGE_AT } from '@volter/teams/server/migrations';
|
|
10
11
|
import { AGENT_ACTIVE, AGENT_DESCENDANTS } from '@volter/teams/server/agent-authority';
|
|
11
12
|
import { APP_LINEAGE_REVOKED, appGrantAllows } from '@volter/teams/server/auth';
|
|
@@ -157,6 +158,8 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
157
158
|
guardedTransaction: ({guards=[],statements}) => tx(() => { for(const guard of guards){const row=get(guard.sql,guard.params);if(!row){const error=new Error(guard.message??'transaction predicate changed');error.code=guard.code??'conflict';error.status=guard.status??409;throw error;}}return statements.map(s=>s.kind==='query'?all(s.sql,s.params):s.kind==='one'?get(s.sql,s.params):run(s.sql,s.params));}),
|
|
158
159
|
journalTransaction: ({teamId,guards=[],statements}) => tx(() => { for(const guard of guards){const row=get(guard.sql,guard.params);if(!row){const error=new Error(guard.message??'transaction predicate changed');error.code=guard.code??'conflict';error.status=guard.status??409;throw error;}}const team=get('SELECT journal_revision FROM teams WHERE id=?',[teamId]);if(!team)throw new Error('team not found');const revision=team.journal_revision+1;const results=statements.map(s=>{if(s.kind==='capture_messages')return applyCaptureMessages({get,run},s.capture,revision);const params=(s.params??[]).map(value=>journalParam(value,revision));return s.kind==='query'?all(s.sql,params):s.kind==='one'?get(s.sql,params):run(s.sql,params);});return{revision,results};}),
|
|
159
160
|
ingestOtlpBatch: args => tx(() => ingestOtlpBatch({get,all,run},args)),
|
|
161
|
+
resolveCapturePrefix: args => tx(() => resolveCapturePrefix({get,run},args)),
|
|
162
|
+
warmCapturePrefixes: () => tx(() => warmCapturePrefixes({get,run})),
|
|
160
163
|
correlateSessions: args => tx(() => correlateSessions({get,all,run},args)),
|
|
161
164
|
listEffects: args => tx(() => listEffects({get,all,run},args)),
|
|
162
165
|
getEffect: args => tx(() => getEffect({get,all,run},args)),
|
package/worker/index.mjs
CHANGED
|
@@ -82,7 +82,16 @@ function durableSql(storage, meter) {
|
|
|
82
82
|
export class TeamsServer extends DurableObject {
|
|
83
83
|
constructor(ctx, env) {
|
|
84
84
|
super(ctx, env);
|
|
85
|
-
this.
|
|
85
|
+
this.nextMaintenanceAt = Date.now() + 5_000;
|
|
86
|
+
this.diagnosticPersistence = { interval_ms: 5_000, writes_since_activation: 0, last_saved_at: null, restored: false, restore_error: false };
|
|
87
|
+
this.work = new WorkMeter({ retainSlow: records => {
|
|
88
|
+
const now = Date.now(), state = this.diagnosticPersistence;
|
|
89
|
+
// Keep every slow record in memory, but never rewrite the persisted ring
|
|
90
|
+
// on each slow request. A reset can lose the most recent cooldown window.
|
|
91
|
+
if (state.last_saved_at != null && now - state.last_saved_at < state.interval_ms) return;
|
|
92
|
+
ctx.storage.kv.put('teams_work_slow_v1', JSON.stringify(records));
|
|
93
|
+
state.last_saved_at = now; state.writes_since_activation++;
|
|
94
|
+
}, advanceClock: () => scheduler.wait(0), sqlTiming: false });
|
|
86
95
|
this.ready = ctx.blockConcurrencyWhile(() => this.work.run('activation', async () => {
|
|
87
96
|
// Read before migrations/maintenance: allocation failures must not hide
|
|
88
97
|
// the table responsible, including hosts with a lower storage limit.
|
|
@@ -90,10 +99,19 @@ export class TeamsServer extends DurableObject {
|
|
|
90
99
|
const db = durableSql(ctx.storage, this.work);
|
|
91
100
|
migrate({ exec: db.exec, tx: db.tx, version: () => ctx.storage.kv.get('schema_version') ?? 0, setVersion: (n) => ctx.storage.kv.put('schema_version', n) });
|
|
92
101
|
const retained = ctx.storage.kv.get('teams_work_slow_v1');
|
|
93
|
-
if (retained)
|
|
102
|
+
if (retained) {
|
|
103
|
+
try {
|
|
104
|
+
const records = JSON.parse(retained);
|
|
105
|
+
if (!Array.isArray(records)) throw new Error('invalid diagnostic history');
|
|
106
|
+
this.work.restoreSlow(records); this.diagnosticPersistence.restored = true;
|
|
107
|
+
} catch {
|
|
108
|
+
this.diagnosticPersistence.restore_error = true;
|
|
109
|
+
console.warn('Teams diagnostic history could not be read; serving with an empty diagnostic history');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
94
112
|
this.api = createTeamsApi({ publicOrigin: normalizePublicOrigin(env.PUBLIC_ORIGIN), appRootSecret: env.SUPERCODE_TEAMS_BOOTSTRAP_TOKEN || null });
|
|
95
113
|
this.api.work = (name, fn, detail) => this.work.run(name, fn, detail);
|
|
96
|
-
this.api.workReport = () => this.work.report();
|
|
114
|
+
this.api.workReport = () => ({ ...this.work.report(), persistence: { ...this.diagnosticPersistence } });
|
|
97
115
|
this.api.storageUsage = () => {
|
|
98
116
|
const sql = ctx.storage.sql;
|
|
99
117
|
let freeBytes = null;
|
|
@@ -101,6 +119,10 @@ export class TeamsServer extends DurableObject {
|
|
|
101
119
|
return { allocated: sql.databaseSize, free_bytes: freeBytes, used: Math.max(0, sql.databaseSize - (freeBytes ?? 0)), used_is_upper_bound: freeBytes === null, limit: 10 * 1024 ** 3 };
|
|
102
120
|
};
|
|
103
121
|
this.api.attach({ store: new DirectStore(createStoreHandlers(db)), harnessRoutes });
|
|
122
|
+
this.api.scheduleCapturePrefixWork = async () => {
|
|
123
|
+
const soon = Date.now() + 100, armed = await ctx.storage.getAlarm();
|
|
124
|
+
if (armed == null || armed > soon) await ctx.storage.setAlarm(soon);
|
|
125
|
+
};
|
|
104
126
|
const existing = await this.api.store.one("SELECT value FROM server_meta WHERE key='server_id'");
|
|
105
127
|
if (existing) this.api.attach({ serverId: existing.value });
|
|
106
128
|
else {
|
|
@@ -154,18 +176,31 @@ export class TeamsServer extends DurableObject {
|
|
|
154
176
|
|
|
155
177
|
async alarm() {
|
|
156
178
|
await this.ready;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
179
|
+
let prefixes = null;
|
|
180
|
+
if (this.api.serverId) prefixes = await this.work.run('capturePrefix', async () => {
|
|
181
|
+
const result = await this.api.store.call('warmCapturePrefixes');
|
|
182
|
+
this.work.detail({ prepared_messages: result.messages, prepared_bytes: result.bytes, backlog: result.backlog });
|
|
183
|
+
return result;
|
|
184
|
+
}).catch(error => { console.error(`capture prefix preparation failed: ${error.message}`); return { backlog: true }; });
|
|
185
|
+
let done = null;
|
|
186
|
+
if (this.api.serverId && Date.now() >= this.nextMaintenanceAt) {
|
|
187
|
+
done = await this.work.run('alarm', () => maintain(this.api)).catch(error => { console.error(`maintain failed: ${error.message}`); return null; });
|
|
188
|
+
this.nextMaintenanceAt = Date.now() + (done ? maintainDelay(this.api, done) : MAINTAIN_MS);
|
|
189
|
+
const usage = this.api.storageUsage();
|
|
190
|
+
if (usage.used >= usage.limit * 0.8) console.warn(`Teams storage pressure: ${JSON.stringify(usage)}; native upload admission stops at 90%`);
|
|
191
|
+
}
|
|
160
192
|
// The database's size on every full pass, where `wrangler tail` reads it.
|
|
161
|
-
if (!done
|
|
193
|
+
if (done && !done.backlog) {
|
|
162
194
|
// What grows: free pages (space deletes left for reuse), and each busy table's newest rowid (its insert rate).
|
|
163
195
|
const sql = this.ctx.storage.sql, one = (query) => { try { return [...sql.exec(query)][0] ?? {}; } catch { return {}; } };
|
|
164
196
|
const tables = ['messages', 'capture_parts', 'captures', 'session_versions', 'idempotency', 'journal'].map((name) => `${name}=${one(`SELECT MAX(rowid) m FROM ${name}`).m ?? '-'}`).join(' ');
|
|
165
197
|
console.log(`maintain: database ${(sql.databaseSize / 1048576).toFixed(1)} MB free_pages=${one('PRAGMA freelist_count').freelist_count ?? '-'} ${tables}`);
|
|
166
198
|
}
|
|
167
199
|
// A backlog (versions or parts left over) is worked again in seconds, not a minute.
|
|
168
|
-
|
|
200
|
+
// A prefix batch does not accelerate retention. The one alarm owns two
|
|
201
|
+
// separate deadlines, so warm-up never repeats maintenance every 100 ms.
|
|
202
|
+
const next = Math.max(1, this.nextMaintenanceAt - Date.now());
|
|
203
|
+
await this.ctx.storage.setAlarm(Date.now() + (prefixes?.backlog ? Math.min(100, next) : next));
|
|
169
204
|
}
|
|
170
205
|
}
|
|
171
206
|
|