@volter/supercode-teams 0.2.66 → 0.2.68
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/connector/index.mjs +24 -3
- package/contracts/index.mjs +7 -2
- package/machine.mjs +2 -2
- package/package.json +1 -1
- package/server/harness-routes.mjs +14 -2
package/connector/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { mkdirSync, readFileSync, writeFileSync, renameSync, existsSync, chmodSync, realpathSync, statSync, openSync, closeSync, fsyncSync, unlinkSync, readSync, readdirSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, join } from 'node:path';
|
|
3
3
|
import { randomBytes, createHash } from 'node:crypto';
|
|
4
|
-
import { canonicalJson } from '../contracts/index.mjs';
|
|
4
|
+
import { canonicalJson, carryDigest } from '../contracts/index.mjs';
|
|
5
5
|
import { EffectConsumer } from './effects.mjs';
|
|
6
6
|
import { NativeEffects } from './native-effects.mjs';
|
|
7
7
|
|
|
@@ -69,6 +69,8 @@ const LARGE_CAPTURE_BYTES=8*1024*1024,LARGE_CAPTURE_EVERY_MS=5*60000,NATIVE_MAX_
|
|
|
69
69
|
// Each capture writes the session's whole transcript as a new version on the server, so a session is captured at
|
|
70
70
|
// most this often (a later change is captured when the wait ends): a busy fleet filled the server's database.
|
|
71
71
|
const CAPTURE_FLOOR_MS=5000,LARGE_CAPTURE_FLOOR_MS=20000;
|
|
72
|
+
// A capture carries its unchanged prefix (names it by digest instead of sending it) when it is at least this long.
|
|
73
|
+
const CARRY_MIN_MESSAGES=20;
|
|
72
74
|
// A turn a Room member typed here waits this long to be found in its session's transcript.
|
|
73
75
|
const TURN_NOTE_MS=24*3600000;
|
|
74
76
|
const messageText=value=>typeof value?.content==='string'?value.content.trim():Array.isArray(value?.content)?value.content.map(part=>typeof part==='string'?part:part?.type==='text'?part.text??'':'').join('').trim():'';
|
|
@@ -449,6 +451,20 @@ export class TeamsConnector {
|
|
|
449
451
|
const entry=entries[index];if(entry.value)entry.value={...entry.value,metadata:{...entry.value.metadata,...annotations}};else entry.metadata={...entry.metadata,...annotations};
|
|
450
452
|
});
|
|
451
453
|
}
|
|
454
|
+
/**
|
|
455
|
+
* How many leading messages of this capture the server already holds from this source's last committed capture
|
|
456
|
+
* (same keys and digests at the same places), or 0: then the capture is sent whole. Short prefixes are not worth
|
|
457
|
+
* carrying; one message is always sent (a part holds at least one).
|
|
458
|
+
*/
|
|
459
|
+
carriedPrefix(previous,messages,{ruleRevision,backfillMode,clearBaseline}){
|
|
460
|
+
if(!previous?.observed_keys?.length||previous.carry_refused||previous.last_rule_revision!==ruleRevision||previous.backfill_mode!==backfillMode)return 0;
|
|
461
|
+
// What the server holds is what was sent: the observed messages without a kept-back baseline.
|
|
462
|
+
const kept=previous.baseline&&!clearBaseline?new Set(previous.baseline):null;
|
|
463
|
+
const held=previous.observed_keys.map((key,index)=>[key,previous.observed_digests?.[index]]).filter(([key])=>!kept?.has(key));
|
|
464
|
+
const limit=Math.min(messages.length-1,held.length);let count=0;
|
|
465
|
+
while(count<limit&&messages[count].native_key===held[count][0]&&hash(canonicalJson(messages[count].value))===held[count][1])count++;
|
|
466
|
+
return count>=CARRY_MIN_MESSAGES?count:0;
|
|
467
|
+
}
|
|
452
468
|
largeSession({locator}){
|
|
453
469
|
try{return locator?.storage?.kind==='file'&&statSync(realpathSync(locator.storage.path)).size>=LARGE_CAPTURE_BYTES;}catch{return false;}
|
|
454
470
|
}
|
|
@@ -544,7 +560,12 @@ export class TeamsConnector {
|
|
|
544
560
|
if(!messages.length){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
545
561
|
const captureId=`cap_${randomBytes(16).toString('hex')}`,generation=effectivePrevious?.generation??1,sequence=(effectivePrevious?.sequence??0)+1;
|
|
546
562
|
const begin={native_session_id:nativeSessionId,harness,generation,sequence,capture_id:captureId,previous_digest:effectivePrevious?.digest??null,visibility,rule_id:ruleId,rule_revision:ruleRevision};
|
|
547
|
-
|
|
563
|
+
// Only what the server does not hold goes over the wire: the longest prefix this source last committed unchanged
|
|
564
|
+
// (the outbox is empty here, so that is the session's current version) is named by its digest, not sent again.
|
|
565
|
+
const carried=this.carriedPrefix(effectivePrevious,messages,{ruleRevision,backfillMode:backfillPolicy.mode,clearBaseline});
|
|
566
|
+
if(carried)begin.carry={count:carried,digest:carryDigest(messages.slice(0,carried))};
|
|
567
|
+
const sent=carried?messages.slice(carried):messages;
|
|
568
|
+
const parts=[];for(let index=0;index<sent.length;index+=500)parts.push({capture_id:captureId,index:parts.length,messages:sent.slice(index,index+500)});
|
|
548
569
|
let artifacts=[];
|
|
549
570
|
// Native stores normally live in harness homes outside the repository. The locator is
|
|
550
571
|
// accepted only when it came from this configured core discovery, and raw bytes are safe
|
|
@@ -656,6 +677,6 @@ export class TeamsConnector {
|
|
|
656
677
|
if(!Array.isArray(configuration?.rules))return false;
|
|
657
678
|
return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
|
|
658
679
|
}
|
|
659
|
-
async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);try{receipt=await this.sendCapture(item);}catch(error){if(error.status===400&&error.code==='invalid_request'){this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused as malformed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.nativeDeferred)next.native_deferred=true;else delete next.native_deferred;if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
|
|
680
|
+
async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;this.dropLostArtifacts(item);try{receipt=await this.sendCapture(item);}catch(error){if(error.status===409&&error.code==='carry_mismatch'){const key=`${item.begin.harness}:${item.begin.native_session_id}`;this.lastError=error;process.stderr.write(`supercode teams: the server does not hold what a capture of ${item.begin.harness} session ${item.begin.native_session_id} carried; it goes whole next time\n`);this.state.outbox.shift();if(this.state.origins[key])this.state.origins[key]={...this.state.origins[key],observed_keys:[],observed_digests:[],carry_refused:true};this.save();continue;}if(error.status===400&&error.code==='invalid_request'){this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id} the server refused as malformed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}if(!await this.ruleRejected(item,error))throw error;this.lastError=error;process.stderr.write(`supercode teams: dropped a queued capture of ${item.begin.harness} session ${item.begin.native_session_id}; its sync rule changed or was removed: ${error.message}\n`);this.state.outbox.shift();this.save();continue;}const key=`${item.begin.harness}:${item.begin.native_session_id}`,prior=this.state.origins[key]??{},next={...prior,generation:item.begin.generation,sequence:item.begin.sequence,digest:item.commit.digest,observed_keys:item.observed?.keys??prior.observed_keys,observed_digests:item.observed?.digests??prior.observed_digests,last_rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode};if(item.clearBaseline){delete next.baseline;delete next.baseline_digests;delete next.baseline_tool_tail;}if(item.nativeDeferred)next.native_deferred=true;else delete next.native_deferred;if(!item.begin.carry)delete next.carry_refused;if(item.stamp&&item.begin.rule_id)next.capture_stamps={...prior.capture_stamps,[item.begin.rule_id]:{stamp:item.stamp,rule_revision:item.begin.rule_revision,backfill_mode:item.backfillMode}};this.state.origins[key]=next;this.state.outbox.shift();this.save();if(item.artifacts?.length)this.pruneSpill();latest=receipt;}return latest;}
|
|
660
681
|
stop(){if(this.timer)clearInterval(this.timer);if(this.heartbeatTimer)clearInterval(this.heartbeatTimer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
|
|
661
682
|
}
|
package/contracts/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ContractError, canonicalJson, validationFailure } from '@volter/teams/server/wire';
|
|
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
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']);
|
|
@@ -33,5 +33,10 @@ export function validateNormalizedMessage(value) {
|
|
|
33
33
|
for(const [key,item] of Object.entries(metadata))if(typeof item!=='string')throw new ContractError(`message.metadata.${key} must be a string`);
|
|
34
34
|
return value;
|
|
35
35
|
}
|
|
36
|
-
|
|
36
|
+
/**
|
|
37
|
+
* The digest of a capture's carried prefix: the messages its session's current version already holds at the first
|
|
38
|
+
* `count` places, which the capture names by this digest instead of sending again (begin.carry).
|
|
39
|
+
*/
|
|
40
|
+
export function carryDigest(messages) { return sha256(canonicalJson(messages.map((m) => ({ native_key: m.native_key, recorded_at: m.recorded_at ?? null, value: m.value })))); }
|
|
41
|
+
export function validateCaptureBegin(value) { exact(value, ['native_session_id','harness','generation','sequence','capture_id','previous_digest','visibility','rule_id','rule_revision','carry'], 'capture'); if (value.carry !== undefined) { exact(value.carry, ['count','digest'], 'capture.carry'); integer(value.carry.count, 'capture.carry.count', 1); string(value.carry.digest, 'capture.carry.digest', {min:64,max:64}); } string(value.native_session_id, 'capture.native_session_id', {max:512}); string(value.harness, 'capture.harness'); integer(value.generation,'capture.generation',1); integer(value.sequence,'capture.sequence',1); validateId(value.capture_id,'capture.capture_id'); if (value.previous_digest !== null) string(value.previous_digest,'capture.previous_digest',{min:64,max:64}); member(value.visibility,['team','restricted'],'capture.visibility'); if (value.rule_id !== null) validateId(value.rule_id,'capture.rule_id'); if ((value.rule_id === null) !== (value.rule_revision === null)) throw new ContractError('rule_id and rule_revision must both be null or present'); if (value.rule_revision !== null) integer(value.rule_revision,'capture.rule_revision',1); return value; }
|
|
37
42
|
export function validateCapturePart(value) { exact(value,['capture_id','index','messages'],'part'); validateId(value.capture_id,'part.capture_id'); integer(value.index,'part.index'); if (!Array.isArray(value.messages) || value.messages.length < 1 || value.messages.length > 500) throw new ContractError('part.messages must contain 1..500 messages'); for (const [i,m] of value.messages.entries()) { exact(m,['native_key','recorded_at','value'],`part.messages[${i}]`); string(m.native_key,`part.messages[${i}].native_key`,{max:512}); if (m.recorded_at !== null) integer(m.recorded_at,`part.messages[${i}].recorded_at`); validateNormalizedMessage(m.value); } return value; }
|
package/machine.mjs
CHANGED
|
@@ -118,7 +118,7 @@ function launchCommand(program, args) {
|
|
|
118
118
|
*/
|
|
119
119
|
/** How a pane the host keeps after its program ended (`dead`) ended, as `paneExit` says it. */
|
|
120
120
|
function deadPaneExit(session) {
|
|
121
|
-
return Number.isInteger(session.exitCode) ? { failed: session.exitCode !== 0, how: `status ${session.exitCode}` } : { failed: true, how: '
|
|
121
|
+
return Number.isInteger(session.exitCode) ? { failed: session.exitCode !== 0, how: `status ${session.exitCode}` } : { failed: true, how: 'its exit status unknown' };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
124
|
/** How often a pane whose launcher asked to be told of its end is looked for. */
|
|
@@ -155,7 +155,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
155
155
|
// Windows has no tmux: its panes live in the machine's pane server, a process of their own that outlives this
|
|
156
156
|
// daemon (a restart or an upgrade never ends a pane), as tmux panes live in tmux's server.
|
|
157
157
|
if (process.platform === 'win32' && !terminal.PaneServerHost) throw new Error('panes on Windows need a newer @volter/supercode-terminal (one with PaneServerHost)');
|
|
158
|
-
this.host = process.platform === 'win32' ? new terminal.PaneServerHost({ pipe: `\\\\.\\pipe\\supercode-panes-${fingerprint(this.identity.publicKeyPem)}
|
|
158
|
+
this.host = process.platform === 'win32' ? new terminal.PaneServerHost({ pipe: `\\\\.\\pipe\\supercode-panes-${fingerprint(this.identity.publicKeyPem)}`, runtimeDir: join(this.home, 'pane-server') }) : new TmuxTerminalHost({
|
|
159
159
|
// Stable owner id so panes this daemon launched stay "owned" across restarts.
|
|
160
160
|
ownerId: `supercode-teams-${fingerprint(this.identity.publicKeyPem)}`,
|
|
161
161
|
...(tmuxSocket ? { socket: tmuxSocket } : {}),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.68",
|
|
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",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// idempotency check, and last among a team's routes. Each hook returns true when it
|
|
8
8
|
// answered the request.
|
|
9
9
|
import { bearer, hashToken, canRead, canManage, isAdmin } from '@volter/teams/server/auth';
|
|
10
|
-
import { validateListQuery, validateSearchQuery, validateReadQuery, validateCaptureBegin, validateCapturePart, canonicalJson } from '../contracts/index.mjs';
|
|
10
|
+
import { validateListQuery, validateSearchQuery, validateReadQuery, validateCaptureBegin, validateCapturePart, canonicalJson, carryDigest } from '../contracts/index.mjs';
|
|
11
11
|
import { normalizeSearch, textFromContent, cursorHash, cursorToken } from './catalog.mjs';
|
|
12
12
|
import { captureDigest, captureMessages, handleOtlpRoute, sha256, validateArtifactName } from './ingest.mjs';
|
|
13
13
|
import { handleCatalogEvents, handleSessionEvents, sessionNames } from './event-routes.mjs';
|
|
@@ -288,10 +288,22 @@ async function artifactChunk(api,req,res,auth,sourceId,uploadId,index,requestId)
|
|
|
288
288
|
],{status:200,body:response,targetId:uploadId});
|
|
289
289
|
send(res,200,response,requestId);
|
|
290
290
|
}
|
|
291
|
-
async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){const source=await sourceAuth(api,auth,sourceId);const commit=await parseJson(req);exact(commit,['capture_id','parts','digest','title','repository','reported_user','recorded_activity_at','parent_native_id','coverage','artifacts','native_bundle']);if(commit.capture_id!==captureId||!Number.isSafeInteger(commit.parts)||commit.parts<1)fail(400,'invalid_request','invalid commit');const cap=await api.store.one('SELECT * FROM captures WHERE team_id=? AND id=? AND source_id=?',[auth.team_id,captureId,sourceId]);if(await expireStaging(api,auth.team_id,sourceId,captureId,'captures',cap))fail(409,'resync_required','capture reservation expired');if(!cap)fail(404,'not_found','capture not found');if(cap.state==='committed'){const receipt=JSON.parse(cap.receipt_json);if(receipt.digest!==commit.digest)fail(409,'capture_conflict','commit replay differs');receipt.duplicate=true;return send(res,200,result(api,auth,receipt),requestId);}const rows=await api.store.query('SELECT * FROM capture_parts WHERE team_id=? AND capture_id=? ORDER BY part_index',[auth.team_id,captureId]);if(rows.length!==commit.parts)fail(409,'batch_gap','capture parts incomplete');const begin=JSON.parse(cap.begin_json);if(source.kind==='connector'){const rule=await api.store.one('SELECT * FROM sync_rules WHERE team_id=? AND id=? AND source_id=? AND machine_id=?',[auth.team_id,begin.rule_id,sourceId,auth.machine_id]);if(!rule||rule.state!=='active'||rule.revision!==begin.rule_revision||rule.visibility!==begin.visibility)fail(rule?.state==='paused'?409:403,rule?.state==='paused'?'source_paused':'forbidden','sync rule changed before commit');}const parts=rows.map(x=>JSON.parse(x.payload_json));if(captureDigest(begin,parts,commit)!==commit.digest)fail(409,'capture_conflict','capture digest differs');
|
|
291
|
+
async function captureCommit(api,req,res,auth,sourceId,captureId,requestId){const source=await sourceAuth(api,auth,sourceId);const commit=await parseJson(req);exact(commit,['capture_id','parts','digest','title','repository','reported_user','recorded_activity_at','parent_native_id','coverage','artifacts','native_bundle']);if(commit.capture_id!==captureId||!Number.isSafeInteger(commit.parts)||commit.parts<1)fail(400,'invalid_request','invalid commit');const cap=await api.store.one('SELECT * FROM captures WHERE team_id=? AND id=? AND source_id=?',[auth.team_id,captureId,sourceId]);if(await expireStaging(api,auth.team_id,sourceId,captureId,'captures',cap))fail(409,'resync_required','capture reservation expired');if(!cap)fail(404,'not_found','capture not found');if(cap.state==='committed'){const receipt=JSON.parse(cap.receipt_json);if(receipt.digest!==commit.digest)fail(409,'capture_conflict','commit replay differs');receipt.duplicate=true;return send(res,200,result(api,auth,receipt),requestId);}const rows=await api.store.query('SELECT * FROM capture_parts WHERE team_id=? AND capture_id=? ORDER BY part_index',[auth.team_id,captureId]);if(rows.length!==commit.parts)fail(409,'batch_gap','capture parts incomplete');const begin=JSON.parse(cap.begin_json);if(source.kind==='connector'){const rule=await api.store.one('SELECT * FROM sync_rules WHERE team_id=? AND id=? AND source_id=? AND machine_id=?',[auth.team_id,begin.rule_id,sourceId,auth.machine_id]);if(!rule||rule.state!=='active'||rule.revision!==begin.rule_revision||rule.visibility!==begin.visibility)fail(rule?.state==='paused'?409:403,rule?.state==='paused'?'source_paused':'forbidden','sync rule changed before commit');}const parts=rows.map(x=>JSON.parse(x.payload_json));if(captureDigest(begin,parts,commit)!==commit.digest)fail(409,'capture_conflict','capture digest differs');let messages=captureMessages(parts);if(messages.length>100000)fail(413,'request_too_large','capture contains too many messages');
|
|
292
292
|
for(const manifest of commit.artifacts){validateArtifactName(manifest.relative_name);const upload=await api.store.one('SELECT source_id FROM uploads WHERE team_id=? AND id=?',[auth.team_id,manifest.upload_id]);if(!upload||upload.source_id!==sourceId)fail(409,'capture_conflict','artifact upload belongs to another source');const chunks=await api.store.query('SELECT c.* FROM artifact_chunks c JOIN uploads u ON u.team_id=c.team_id AND u.id=c.upload_id WHERE c.team_id=? AND c.upload_id=? AND u.source_id=? ORDER BY c.chunk_index',[auth.team_id,manifest.upload_id,sourceId]);const bytes=Buffer.concat(chunks.map(x=>x.bytes));if(chunks.length!==manifest.chunks||bytes.length!==manifest.byte_length||sha256(bytes)!==manifest.sha256)fail(409,'capture_conflict','artifact manifest does not match staged chunks');}
|
|
293
293
|
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');}
|
|
294
294
|
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]);
|
|
295
|
+
// A capture that carries (schema 9's wire half) names its first carry.count messages by digest instead of sending
|
|
296
|
+
// them again: they are the ones this session's current version holds at those places, written by this source. The
|
|
297
|
+
// server rebuilds them from its rows and checks the digest; anything else is refused, and the source sends it whole.
|
|
298
|
+
if(begin.carry){
|
|
299
|
+
const {count,digest}=begin.carry,session=origin?await api.store.one('SELECT revision FROM sessions WHERE team_id=? AND id=?',[auth.team_id,origin.session_id]):null;
|
|
300
|
+
const rows=session?await api.store.query(`SELECT ordinal,source_id,native_key,recorded_at,value_json FROM messages m WHERE m.team_id=? AND m.session_id=? AND ${MESSAGE_AT()} AND m.ordinal<? ORDER BY m.ordinal`,[auth.team_id,origin.session_id,session.revision,session.revision,count]):[];
|
|
301
|
+
const carried=rows.map(row=>({native_key:row.native_key,recorded_at:row.recorded_at,value:JSON.parse(row.value_json)}));
|
|
302
|
+
if(rows.length!==count||rows.some((row,index)=>row.ordinal!==index||row.source_id!==sourceId)||carryDigest(carried)!==digest)fail(409,'carry_mismatch','the carried messages are not what this session holds; send the capture whole');
|
|
303
|
+
const sent=new Set(messages.map(m=>m.native_key));
|
|
304
|
+
if(carried.some(m=>sent.has(m.native_key)))fail(409,'capture_conflict','duplicate native message key');
|
|
305
|
+
messages=[...carried,...messages];
|
|
306
|
+
}
|
|
295
307
|
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'}))];}}
|
|
296
308
|
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=[];
|
|
297
309
|
if(!origin)statements.push({sql:'INSERT INTO sessions(team_id,id,harness,custodian_id,visibility,revision,created_at) VALUES (?,?,?,?,?,?,?)',params:[auth.team_id,sid,cap.harness,source.custodian_id,lineage?'restricted':begin.visibility,revisionParam,boundaryNow]},{sql:'INSERT INTO origins(team_id,source_id,harness,native_id,session_id,generation,sequence,digest) VALUES (?,?,?,?,?,?,?,?)',params:[auth.team_id,sourceId,cap.harness,cap.native_id,sid,cap.generation,cap.sequence,commit.digest]});else statements.push({sql:'UPDATE sessions SET revision=? WHERE team_id=? AND id=?',params:[revisionParam,auth.team_id,sid]},{sql:'UPDATE origins SET generation=?,sequence=?,digest=? WHERE team_id=? AND source_id=? AND harness=? AND native_id=?',params:[cap.generation,cap.sequence,commit.digest,auth.team_id,sourceId,cap.harness,cap.native_id]});
|