@volter/supercode-teams 0.2.21 → 0.2.22
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/bin/workspace/roots.mjs +48 -0
- package/bin/workspace.mjs +9 -2
- package/connector/index.mjs +101 -25
- package/package.json +2 -2
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const canonical = path => { try { return realpathSync.native(path); } catch { return resolve(path); } };
|
|
6
|
+
const folded = (path, platform) => platform === 'win32' || platform === 'darwin' ? path.toLowerCase() : path;
|
|
7
|
+
const contains = (outer, inner) => { const name = relative(outer, inner); return name === '' || (!isAbsolute(name) && name !== '..' && !name.startsWith('..' + sep)); };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Why connecting `path` as a Teams sync root would share every session on this machine, or null.
|
|
11
|
+
* A rule covers its root and every folder under it, so a filesystem or drive root, the user's home, or
|
|
12
|
+
* a folder holding the home (`/Users`, `C:\Users`) publishes everything the harnesses ever recorded.
|
|
13
|
+
*/
|
|
14
|
+
export function broadRootReason(path, { home = homedir(), platform = process.platform } = {}) {
|
|
15
|
+
const root = canonical(path);
|
|
16
|
+
if (dirname(root) === root) return 'a filesystem root';
|
|
17
|
+
if (!home) return null;
|
|
18
|
+
const user = canonical(home);
|
|
19
|
+
if (folded(root, platform) === folded(user, platform)) return 'your home directory';
|
|
20
|
+
if (contains(folded(root, platform), folded(user, platform))) return 'a folder that holds your home directory';
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* How `teams connect` registers `path`: the fields its saved root carries.
|
|
26
|
+
* - An ordinary folder: `{}` (the rule covers the folder and every folder under it).
|
|
27
|
+
* - A broad root with --force, or one accepted with --force before: `{ broad_accepted: true }`.
|
|
28
|
+
* - A broad root this enrollment already had before rules covered subfolders (saved without
|
|
29
|
+
* `broad_accepted`), rerun without a terminal by the background service: `{ exact_only: true }`. It keeps
|
|
30
|
+
* syncing only sessions recorded in that exact folder, as it did, and says so on stderr; refusing it
|
|
31
|
+
* would silently stop the machine's sync.
|
|
32
|
+
* - Otherwise a broad root is refused.
|
|
33
|
+
*/
|
|
34
|
+
export function connectRootScope(path, { force = false, saved = null, interactive = false, warn = message => process.stderr.write(message), ...options } = {}) {
|
|
35
|
+
const previous = saved?.roots?.find(item => item.path === path) ?? null;
|
|
36
|
+
const reason = refuseBroadRoot(path, { force, accepted: Boolean(previous?.broad_accepted) || (Boolean(previous) && !interactive), ...options });
|
|
37
|
+
if (!reason) return {};
|
|
38
|
+
if (force || previous?.broad_accepted) return { broad_accepted: true };
|
|
39
|
+
warn(`supercode teams: ${path} is ${reason}. Its sync rules keep sharing only sessions recorded in that exact folder; sessions in its subfolders are not shared. To share every session under it, run \`supercode teams connect --force\` from that folder.\n`);
|
|
40
|
+
return { exact_only: true };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Refuse a broad root unless the caller passed --force (or accepted this same root before). */
|
|
44
|
+
export function refuseBroadRoot(path, { force = false, accepted = false, verb = 'connect', ...options } = {}) {
|
|
45
|
+
const reason = broadRootReason(path, options);
|
|
46
|
+
if (!reason || force || accepted) return reason;
|
|
47
|
+
throw Object.assign(new Error(`refusing to ${verb} ${path}: it is ${reason}, and a Teams sync rule covers every session whose working directory is that folder or any folder under it, so it would share every session on this machine. Choose a project folder, or pass --force to share them all.`), { code: 'invalid_request', exitCode: 2 });
|
|
48
|
+
}
|
package/bin/workspace.mjs
CHANGED
|
@@ -21,6 +21,7 @@ import { volterDeviceSignIn } from './workspace/volter-device.mjs';
|
|
|
21
21
|
import { loginSummary, signInToTeams } from './workspace/volter-login.mjs';
|
|
22
22
|
import { readSharedSignIn, signOut } from '@volter/identity';
|
|
23
23
|
import { createInterface } from 'node:readline/promises';
|
|
24
|
+
import { connectRootScope, refuseBroadRoot } from './workspace/roots.mjs';
|
|
24
25
|
|
|
25
26
|
const ERROR_EXITS = { invalid_request: 2, ambiguous_reference: 2, authentication_required: 3, not_found: 4, forbidden: 4, unsupported: 5, machine_offline: 5, controller_busy: 6, conflict: 6, resync_required: 6, revision_mismatch: 6, idempotency_conflict: 6, batch_gap: 6, capture_conflict: 6, source_paused: 6 };
|
|
26
27
|
|
|
@@ -607,6 +608,7 @@ async function syncCommand(sub, { flags, rest, output, client, context, team, ho
|
|
|
607
608
|
if (!enrollment?.machine?.id) unsupported('this context has no connector enrollment');
|
|
608
609
|
const path = realpathSync(resolve(flags.repo)), root = enrollment.roots?.find(item => item.path === path);
|
|
609
610
|
if (!root) unsupported('the connector has not registered that repository root; restart connect with --cwd PATH');
|
|
611
|
+
refuseBroadRoot(path, { force: flags.force, verb: 'sync' });
|
|
610
612
|
if (flags.harness && !root.harnesses.includes(flags.harness)) unsupported(`the native core does not report a ${flags.harness} discovery adapter`);
|
|
611
613
|
return output(await team.syncRules.create({ machine_id: enrollment.machine.id, source_id: enrollment.source.id, selector: { root_id: root.id, harnesses: flags.harness ? [flags.harness] : root.harnesses }, visibility: flags.visibility ?? 'restricted', backfill: backfill(flags.backfill ?? 'none') }));
|
|
612
614
|
}
|
|
@@ -626,6 +628,11 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
626
628
|
if (verb === 'status') { let reachable = true; let account = null; try { account = await client.me(); } catch { reachable = false; } const saved = state.read(); const running = saved?.process?.socket ? await activeSocket(saved.process.socket) : false; return output({ version: 1, context: scope(context), data: { reachable, account, connector: saved ? { enrolled: true, running, machine: saved.machine, source: saved.source } : { enrolled: false, running: false }, sources: reachable ? (await team.sources.list()).items : [] } }); }
|
|
627
629
|
if (verb === 'connect') {
|
|
628
630
|
let saved = state.read();
|
|
631
|
+
const path = realpathSync(resolve(flags.cwd ?? '.'));
|
|
632
|
+
// A broad root is refused before enrolling anything. The service reruns connect without a terminal, and
|
|
633
|
+
// without --force, for the root accepted here; a broad root enrolled before rules covered subfolders keeps
|
|
634
|
+
// its exact-folder sync there.
|
|
635
|
+
const rootScope = connectRootScope(path, { force: flags.force, saved, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
|
|
629
636
|
if (!saved) { const enrollment_key = crypto.randomUUID(); saved = { version: 1, enrollment_key, phase: 'enrolling' }; state.write(saved); }
|
|
630
637
|
if (!saved.machine) {
|
|
631
638
|
const response = await team.machines.create({ name: flags.name ?? hostname(), enrollment_key: saved.enrollment_key });
|
|
@@ -640,8 +647,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
640
647
|
try { support = await core.supportReport(); } catch (error) { core.close(); throw error; }
|
|
641
648
|
const harnesses = [...new Set((support.harnesses ?? []).map(item => item.id).filter(id => typeof id === 'string'))];
|
|
642
649
|
if (!harnesses.length) { core.close(); unsupported('the native core reported no discoverable harness adapters'); }
|
|
643
|
-
const
|
|
644
|
-
const root = { id: `root_${createHash('sha256').update(`${saved.machine.id}\0${path}`).digest('hex').slice(0, 32)}`, machine_id: saved.machine.id, name: basename(path), harnesses, path };
|
|
650
|
+
const root = { id: `root_${createHash('sha256').update(`${saved.machine.id}\0${path}`).digest('hex').slice(0, 32)}`, machine_id: saved.machine.id, name: basename(path), harnesses, path, ...rootScope };
|
|
645
651
|
saved = state.update(current => ({ ...current, roots: [root] }));
|
|
646
652
|
// Run from a terminal, connect installs the service that keeps this machine connected (launchd or systemd) and
|
|
647
653
|
// returns; the service itself runs connect without a terminal, in the foreground. --foreground runs it here.
|
|
@@ -676,6 +682,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
|
|
|
676
682
|
const hostKeyId = publicKeyId(loadOrCreateKeyPair(paths(daemonHome).machineKey).publicKeyPem);
|
|
677
683
|
const connector = new TeamsConnector({ server: context.origin, teamId: context.team_id, machineId: saved.machine.id, sourceId: saved.source.id, credential: saved.credential.token, stateDir: state.directory, core, hostKeyId });
|
|
678
684
|
const machine = new TeamsMachine({ home: daemonHome, supercode: binary, ...(flags.tmux_socket ? { tmuxSocket: String(flags.tmux_socket) } : {}), link: { server: context.origin, teamId: context.team_id, machineId: saved.machine.id, credential: saved.credential.token, incarnationId: connector.state.incarnation_id, configuration: () => connector.configuration() } });
|
|
685
|
+
machine.on('link', (event) => { if (event.event === 'replaced') process.stderr.write(`supercode teams: ${event.error}. Two connectors were running for this machine; stop one of them (\`supercode teams disconnect\` where it should not run).\n`); });
|
|
679
686
|
const stopAll = async () => { await machine.stop().catch(() => {}); await connector.stop(); await core.close(); await lifecycle.close(); state.update(current => current ? ({ ...current, process: undefined }) : null); };
|
|
680
687
|
try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
|
|
681
688
|
output({ version: 1, context: scope(context), data: { machine: saved.machine, source: saved.source, roots: saved.roots.map(({ path: _path, ...item }) => item), socket: machine.socketPath, foreground: true } });
|
package/connector/index.mjs
CHANGED
|
@@ -25,8 +25,20 @@ function atomic(path, value) {
|
|
|
25
25
|
}
|
|
26
26
|
export class TeamsConnector {
|
|
27
27
|
constructor({server,teamId,machineId,sourceId,credential,stateDir,core,hostKeyId=null,effectExecutor=null,clock={nowMs:()=>Date.now()},fetch:fetchImpl=globalThis.fetch}){Object.assign(this,{server:serverOrigin(server),teamId,machineId,sourceId,credential,stateDir,core,hostKeyId,effectExecutor,clock,fetch:fetchImpl});mkdirSync(stateDir,{recursive:true,mode:0o700});chmodSync(stateDir,0o700);this.statePath=join(stateDir,'connector.json');this.state=existsSync(this.statePath)?JSON.parse(readFileSync(this.statePath,'utf8')):{version:1,sequence:0,origins:{},outbox:[]};this.state.effects??={};this.state.incarnation_id=`inc_${randomBytes(16).toString('hex')}`;this.abort=new AbortController();this.work=Promise.resolve();this.nativeEffects=new NativeEffects(this);this.effects=new EffectConsumer(this,effectExecutor?{prepare:async effect=>({execute:()=>effectExecutor(effect,{core:this.core,signal:this.abort.signal})})}:this.nativeEffects);this.save();}
|
|
28
|
-
save(){atomic(this.statePath,this.state);} async request(method,path,body){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);
|
|
29
|
-
|
|
28
|
+
save(){atomic(this.statePath,this.state);} async request(method,path,body){const json=body===undefined?undefined:JSON.stringify(body),mutation=['POST','PATCH','DELETE'].includes(method);let response;for(let attempt=0;;attempt++){response=await this.fetch(this.server+`/api/v1/teams/${this.teamId}`+path,{method,redirect:'manual',headers:{Authorization:`Bearer ${this.credential}`,'Content-Type':'application/json',...(mutation?{'Idempotency-Key':hash(`${method}\n${path}\n${json??''}`)}:{})},body:json,signal:this.abort.signal});
|
|
29
|
+
// A rate limit is waited out (the server says how long; the same idempotency key makes the retry safe), so a
|
|
30
|
+
// large first sync slows down instead of failing its pass and restarting the connector.
|
|
31
|
+
if(response.status!==429||attempt>=5)break;// The server names the wait in error.details (wire.mjs validationFailure); a Retry-After header is the fallback.
|
|
32
|
+
const limited=await response.json().catch(()=>null),named=limited?.error?.details?.retry_after_seconds??limited?.error?.retry_after_seconds??response.headers?.get?.('retry-after'),asked=named==null||named===''?NaN:Number(named),seconds=Number.isFinite(asked)?Math.min(60,Math.max(0,asked)):1;
|
|
33
|
+
await new Promise((resolve,reject)=>{const stop=()=>{clearTimeout(timer);reject(this.abort.signal.reason);},timer=setTimeout(()=>{this.abort.signal.removeEventListener('abort',stop);resolve();},seconds*1000);this.abort.signal.addEventListener('abort',stop,{once:true});});}if(response.type==='opaqueredirect'||response.status>=300&&response.status<400)throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400});const value=await response.json();if(!response.ok)throw Object.assign(new Error(value.error?.message??`HTTP ${response.status}`),value.error,{status:response.status});return value.data;}
|
|
34
|
+
async configuration(){const value=await this.request('GET',`/machines/${this.machineId}/configuration`);this.configurationValue=value;this.pruneStamps(value);return value;}
|
|
35
|
+
// A capture stamp belongs to one rule; once that rule is no longer active its stamps are dropped.
|
|
36
|
+
pruneStamps(configuration){
|
|
37
|
+
if(!Array.isArray(configuration?.rules))return;
|
|
38
|
+
const active=new Set(configuration.rules.filter(rule=>rule.state==='active').map(rule=>rule.id));let pruned=false;
|
|
39
|
+
for(const origin of Object.values(this.state.origins??{}))for(const ruleId of Object.keys(origin?.capture_stamps??{}))if(!active.has(ruleId)){delete origin.capture_stamps[ruleId];pruned=true;}
|
|
40
|
+
if(pruned)this.save();
|
|
41
|
+
}
|
|
30
42
|
async start({bindings=()=>[],roots=()=>[]}={}) {
|
|
31
43
|
await this.configuration();
|
|
32
44
|
const heartbeat = async () => {
|
|
@@ -82,26 +94,37 @@ export class TeamsConnector {
|
|
|
82
94
|
this.nativeEffects.beginDiscovery();
|
|
83
95
|
const activeRules=new Map(configuration.rules.filter(item=>item.state==='active').map(rule=>[rule.id,rule]));
|
|
84
96
|
for(const [key,follower] of this.followers){const rule=activeRules.get(follower.ruleId);if(!rule||rule.revision!==follower.ruleRevision){follower.controller.abort();this.followers.delete(key);}}
|
|
97
|
+
// A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
|
|
98
|
+
// reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
|
|
99
|
+
// machine; rules over the same root and harnesses share the scan.
|
|
100
|
+
const discoveries=new Map();
|
|
85
101
|
for(const rule of activeRules.values()){
|
|
86
102
|
const root=roots.find(item=>item.id===rule.selector.root_id);
|
|
87
103
|
if(!root)continue;
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
104
|
+
const workspace=root.path??root.workspace,subtree=!root.exact_only,discoveryKey=JSON.stringify([workspace,subtree,[...(rule.selector.harnesses??[])].sort(),root.homes??null]);
|
|
105
|
+
if(!discoveries.has(discoveryKey))discoveries.set(discoveryKey,(async()=>{
|
|
106
|
+
const descriptors=[];
|
|
107
|
+
let cursor;
|
|
108
|
+
do {
|
|
109
|
+
// A broad root enrolled before rules covered subfolders keeps its exact-folder discovery.
|
|
110
|
+
const page=await this.core.discover({workspace,...(subtree?{workspace_subtree:true}:{}),harnesses:rule.selector.harnesses,homes:root.homes,limit:2048,...(cursor?{cursor}:{})});
|
|
111
|
+
descriptors.push(...(page.sessions??page.entries??[]));
|
|
112
|
+
cursor=page.next_cursor??page.nextCursor??null;
|
|
113
|
+
} while(cursor);
|
|
114
|
+
return descriptors;
|
|
115
|
+
})());
|
|
116
|
+
const descriptors=await discoveries.get(discoveryKey);
|
|
95
117
|
for(const descriptor of descriptors){
|
|
96
118
|
const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
|
|
97
119
|
if(!locator||!nativeSessionId||!harness)continue;
|
|
98
|
-
const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:
|
|
120
|
+
const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:workspace,configuration};
|
|
121
|
+
// A session whose native store has not changed since this rule last captured it is not reloaded.
|
|
99
122
|
// One session that cannot be captured (a transcript that rewrote its history needs an explicit resync)
|
|
100
123
|
// is recorded, logged and skipped; it must not stop every other session, or the connector itself, from
|
|
101
124
|
// syncing. An error of the connection itself (the outbox, authentication, the server, a stop) still ends
|
|
102
125
|
// the pass. The session keeps its native binding either way, so it can still be attached to.
|
|
103
126
|
let captured = true;
|
|
104
|
-
try { await this.capture(options); } catch (error) {
|
|
127
|
+
if (this.sessionChanged(options)) try { await this.capture(options); } catch (error) {
|
|
105
128
|
if (error.outbox || error.status === 401 || error.status === 403 || error.status >= 500 || this.abort.signal.aborted) throw error;
|
|
106
129
|
captured = false; this.lastError = error;
|
|
107
130
|
process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
|
|
@@ -111,42 +134,83 @@ export class TeamsConnector {
|
|
|
111
134
|
const key=`${rule.id}:${harness}:${nativeSessionId}`;
|
|
112
135
|
if(this.followers.has(key)||typeof this.core.session(locator).follow!=='function')continue;
|
|
113
136
|
const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
|
|
114
|
-
|
|
137
|
+
// A follow event may land between beats, after the rule changed: it reads the configuration afresh.
|
|
138
|
+
const followed={...options,configuration:null,stamp:undefined};
|
|
139
|
+
const task=(async()=>{for await(const _event of this.core.session(locator).follow({signal:controller.signal})){if(controller.signal.aborted)break;await this.capture(followed);}})().catch(error=>{if(!controller.signal.aborted)this.lastError=error;}).finally(()=>{this.abort.signal.removeEventListener('abort',stop);this.followers.delete(key);});
|
|
115
140
|
this.followers.set(key,{ruleId:rule.id,ruleRevision:rule.revision,controller,task});
|
|
116
141
|
}
|
|
117
142
|
}
|
|
118
143
|
this.nativeEffects.endDiscovery();
|
|
119
144
|
}
|
|
145
|
+
// What a session's native store looked like when discovery listed it: its file's size and modification
|
|
146
|
+
// time (read before any load, so a write after it reads as a change), those of the file's SQLite write-ahead
|
|
147
|
+
// log, and the descriptor's updated_at_ms and message_count. A SQLite store (Hermes state.db, OpenCode,
|
|
148
|
+
// Goose) is shared by many sessions, and a live one appends to `<path>-wal` until a checkpoint while the
|
|
149
|
+
// database file, and a Hermes session's updated_at_ms (its start until it ends), stay put; its message count
|
|
150
|
+
// still moves. Null when nothing is known; such a session is reloaded every beat.
|
|
151
|
+
sessionStamp({locator,descriptor}){
|
|
152
|
+
const stat=path=>{try{const value=statSync(path);return `${value.size}:${value.mtimeMs}`;}catch{return null;}};
|
|
153
|
+
const path=typeof locator?.storage?.path==='string'?locator.storage.path:null;
|
|
154
|
+
const file=path?stat(path):null,wal=path?stat(`${path}-wal`):null;
|
|
155
|
+
const updated=Number.isSafeInteger(descriptor?.updated_at_ms)?descriptor.updated_at_ms:null;
|
|
156
|
+
const count=Number.isSafeInteger(descriptor?.message_count)?descriptor.message_count:null;
|
|
157
|
+
return file===null&&updated===null&&count===null?null:JSON.stringify([file,wal,updated,count]);
|
|
158
|
+
}
|
|
159
|
+
sessionChanged(options){
|
|
160
|
+
const stamp=this.sessionStamp(options);options.stamp=stamp;
|
|
161
|
+
if(stamp===null||this.state.outbox.length)return true;
|
|
162
|
+
const rule=options.configuration?.rules?.find(item=>item.id===options.ruleId&&item.revision===options.ruleRevision&&item.state==='active');if(!rule)return true;
|
|
163
|
+
const configured=rule.backfill??options.backfill??'all',mode=typeof configured==='string'?configured:configured.mode;
|
|
164
|
+
const seen=this.state.origins[`${options.harness}:${options.nativeSessionId}`]?.capture_stamps?.[options.ruleId];
|
|
165
|
+
return !(seen&&seen.stamp===stamp&&seen.rule_revision===options.ruleRevision&&seen.backfill_mode===mode);
|
|
166
|
+
}
|
|
167
|
+
recordStamp(key,ruleId,ruleRevision,backfillMode,stamp){
|
|
168
|
+
const origin=this.state.origins[key];
|
|
169
|
+
if(origin&&ruleId&&stamp)origin.capture_stamps={...origin.capture_stamps,[ruleId]:{stamp,rule_revision:ruleRevision,backfill_mode:backfillMode}};
|
|
170
|
+
this.save();
|
|
171
|
+
}
|
|
120
172
|
capture(options) {
|
|
121
173
|
const work=this.work.then(()=>this.captureSerial(options));
|
|
122
174
|
this.work=work.catch(()=>{});
|
|
123
175
|
return work;
|
|
124
176
|
}
|
|
125
|
-
async captureSerial({locator,descriptor=null,harness,nativeSessionId,visibility='restricted',ruleId=null,ruleRevision=null,coverage={completeness:'complete',reasons:[],missing:[]},backfill='all',rootPath=null}) {
|
|
177
|
+
async captureSerial({locator,descriptor=null,harness,nativeSessionId,visibility='restricted',ruleId=null,ruleRevision=null,coverage={completeness:'complete',reasons:[],missing:[]},backfill='all',rootPath=null,configuration=null,stamp}) {
|
|
126
178
|
if(this.state.outbox.length)await this.flushSerial();
|
|
127
|
-
|
|
179
|
+
// Discovery hands each session the configuration its beat fetched; a follow event fetches its own.
|
|
180
|
+
if(stamp===undefined)stamp=this.sessionStamp({locator,descriptor});
|
|
181
|
+
if(!configuration)configuration=await this.configuration();const rule=configuration.rules.find(item=>item.id===ruleId&&item.revision===ruleRevision&&item.state==='active');if(!rule)throw Object.assign(new Error('sync rule is absent, inactive, or stale'),{code:'forbidden'});visibility=rule.visibility;
|
|
128
182
|
const artifactSource=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
|
|
129
183
|
const artifactBefore=artifactSource?statSync(artifactSource):null;
|
|
130
184
|
const loaded=await this.core.session(locator).load(),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
|
|
131
185
|
const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
|
|
132
186
|
let messages=(loaded.messages??loaded.turns??[]).map((entry,index)=>{const value=entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key)));return{native_key:entry.id??entry.native_key??String(index),recorded_at:recordedAt(entry),value};});
|
|
133
|
-
if(previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index]))return null;
|
|
134
|
-
let effectivePrevious=previous;
|
|
135
|
-
if(!previous&&backfillPolicy.mode==='none'){this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>hash(canonicalJson(x.value))),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>hash(canonicalJson(x.value))),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.
|
|
187
|
+
if(previous?.last_rule_revision===ruleRevision&&previous?.backfill_mode===backfillPolicy.mode&&previous?.observed_keys?.length===messages.length&&messages.every((message,index)=>message.native_key===previous.observed_keys[index]&&hash(canonicalJson(message.value))===previous.observed_digests?.[index])){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
188
|
+
let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
|
|
189
|
+
if(!previous&&backfillPolicy.mode==='none'){this.state.origins[key]={generation:1,sequence:0,digest:null,baseline:messages.map(x=>x.native_key),baseline_digests:messages.map(x=>hash(canonicalJson(x.value))),observed_keys:messages.map(x=>x.native_key),observed_digests:messages.map(x=>hash(canonicalJson(x.value))),last_rule_revision:ruleRevision,backfill_mode:backfillPolicy.mode};this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
136
190
|
if(!previous&&backfillPolicy.mode==='since'){
|
|
137
191
|
if(!Number.isSafeInteger(backfillPolicy.since))throw Object.assign(new Error('since backfill requires an integer timestamp'),{code:'invalid_request'});
|
|
138
192
|
const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
|
|
139
193
|
effectivePrevious={generation:1,sequence:0,digest:null,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
|
|
140
194
|
this.state.origins[key]=effectivePrevious;
|
|
141
195
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
196
|
+
// A source keyed by position (a Claude Code transcript is) can change a message it already reported: the core
|
|
197
|
+
// reports a tool call still running as an interrupted placeholder, and the result replaces it at the same
|
|
198
|
+
// position; a compaction or a parser change rewrites more. Every capture is a whole snapshot (the server keeps
|
|
199
|
+
// only the latest capture's messages as the session's history), so a rewrite of what was published is simply
|
|
200
|
+
// republished by the next capture. Only the unpublished baseline of a `none` or `since` rule is a problem:
|
|
201
|
+
// positions no longer identify the history the rule kept back. A `since` rule derives it again from the
|
|
202
|
+
// timestamps; a `none` rule cannot tell old history from new, so that session waits for an explicit
|
|
203
|
+
// `supercode teams sync resume RULE --backfill all` (or a since window), which republishes it whole.
|
|
204
|
+
if(!clearBaseline&&effectivePrevious?.baseline?.length&&effectivePrevious.baseline.every(x=>/^\d+$/.test(x))){
|
|
205
|
+
const baselineMoved=effectivePrevious.baseline.some((native_key,index)=>messages[index]?.native_key!==native_key||hash(canonicalJson(messages[index].value))!==effectivePrevious.baseline_digests?.[index]);
|
|
206
|
+
if(baselineMoved&&backfillPolicy.mode==='since'&&Number.isSafeInteger(backfillPolicy.since)){
|
|
207
|
+
const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
|
|
208
|
+
effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
|
|
209
|
+
this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};
|
|
210
|
+
}else if(baselineMoved)throw Object.assign(new Error(`the history kept back when rule ${ruleId} started was rewritten; run \`supercode teams sync resume ${ruleId} --backfill all\` (or a since window) to republish this session`),{code:'resync_required'});
|
|
146
211
|
}
|
|
147
|
-
const clearBaseline=backfillPolicy.mode==='all';
|
|
148
212
|
if(effectivePrevious?.baseline&&!clearBaseline)messages=messages.filter(x=>!effectivePrevious.baseline.includes(x.native_key));
|
|
149
|
-
if(!messages.length)return null;
|
|
213
|
+
if(!messages.length){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
|
|
150
214
|
const captureId=`cap_${randomBytes(16).toString('hex')}`,generation=effectivePrevious?.generation??1,sequence=(effectivePrevious?.sequence??0)+1;
|
|
151
215
|
const begin={native_session_id:nativeSessionId,harness,generation,sequence,capture_id:captureId,previous_digest:effectivePrevious?.digest??null,visibility,rule_id:ruleId,rule_revision:ruleRevision};
|
|
152
216
|
const parts=[];for(let index=0;index<messages.length;index+=500)parts.push({capture_id:captureId,index:parts.length,messages:messages.slice(index,index+500)});
|
|
@@ -172,7 +236,7 @@ export class TeamsConnector {
|
|
|
172
236
|
}
|
|
173
237
|
const recordedTimes=messages.map(message=>message.recorded_at).filter(Number.isSafeInteger),commit={capture_id:captureId,parts:parts.length,digest:'',title:descriptor?.title??null,repository:null,reported_user:null,recorded_activity_at:recordedTimes.length?Math.max(...recordedTimes):null,parent_native_id:descriptor?.parent_session_id??null,coverage:effectivePrevious?.baseline&&!clearBaseline?{...coverage,completeness:'partial',reasons:[...new Set([...coverage.reasons,'prefix_not_captured'])]}:coverage,artifacts:artifacts.map(item=>item.manifest),...(nativeBundle?{native_bundle:nativeBundle}:{})};
|
|
174
238
|
const digestCommit={...commit};delete digestCommit.digest;commit.digest=hash(canonicalJson({begin,parts,commit:digestCommit}));
|
|
175
|
-
this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
|
|
239
|
+
this.state.outbox.push({begin,parts,commit,artifacts,clearBaseline,backfillMode:backfillPolicy.mode,stamp,observed:{keys:(loaded.messages??loaded.turns??[]).map((entry,index)=>entry.id??entry.native_key??String(index)),digests:(loaded.messages??loaded.turns??[]).map(entry=>hash(canonicalJson(entry.value??Object.fromEntries(Object.entries(entry).filter(([key])=>!['id','native_key','recorded_at','timestamp'].includes(key))))))}});this.save();return this.flushSerial();
|
|
176
240
|
}
|
|
177
241
|
flush() {
|
|
178
242
|
const work=this.work.then(()=>this.flushSerial());
|
|
@@ -181,6 +245,18 @@ export class TeamsConnector {
|
|
|
181
245
|
}
|
|
182
246
|
// Every outbox error is the connection's, not one session's: a capture's own catch passes it on (syncConfiguredSessions).
|
|
183
247
|
async flushSerial(){try{return await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}}
|
|
184
|
-
async
|
|
248
|
+
async sendCapture(item){const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);if(begun.state==='committed')return begun.receipt;for(const part of item.parts)await this.request('PUT',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/parts/${part.index}`,part);for(const artifact of item.artifacts??[])for(const chunk of artifact.chunks)await this.request('PUT',`/sources/${this.sourceId}/uploads/${chunk.upload_id}/chunks/${chunk.index}`,chunk);return this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}
|
|
249
|
+
// True when the server refused a queued capture because its sync rule was removed, paused or revised since it
|
|
250
|
+
// was queued: the capture can never be accepted, so it must not hold every later capture behind it. The
|
|
251
|
+
// refusal is confirmed against a fresh configuration; any other refusal (a revoked credential, a paused
|
|
252
|
+
// source) or a network or server error keeps the capture queued for the next flush.
|
|
253
|
+
async ruleRejected(item,error){
|
|
254
|
+
if(!item.begin.rule_id||!(error.status===403||error.status===404||error.code==='source_paused'))return false;
|
|
255
|
+
let configuration;try{configuration=await this.configuration();}catch{return false;}
|
|
256
|
+
// A configuration without a rule list says nothing about the rule, so the capture stays queued.
|
|
257
|
+
if(!Array.isArray(configuration?.rules))return false;
|
|
258
|
+
return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
|
|
259
|
+
}
|
|
260
|
+
async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];let receipt;try{receipt=await this.sendCapture(item);}catch(error){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;}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();latest=receipt;}return latest;}
|
|
185
261
|
stop(){if(this.timer)clearInterval(this.timer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
|
|
186
262
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.22",
|
|
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",
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"@volter/supercode-harness-sdk": "^0.3.43",
|
|
63
63
|
"@volter/supercode-terminal": "^0.2.30",
|
|
64
64
|
"@volter/identity": "^0.4.0",
|
|
65
|
-
"@volter/teams": "^0.1.
|
|
65
|
+
"@volter/teams": "^0.1.8",
|
|
66
66
|
"@xterm/addon-serialize": "^0.14.0",
|
|
67
67
|
"@xterm/headless": "^6.0.0",
|
|
68
68
|
"protobufjs": "8.8.0",
|