@volter/supercode-teams 0.2.21 → 0.2.23

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.
@@ -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
+ }
@@ -0,0 +1,20 @@
1
+ // How `supercode teams status` and `supercode auth status` show what this machine's connector could not sync
2
+ // (connector/index.mjs readSyncStatus): the sessions not syncing and why, most recent first, and its last error.
3
+ const SHOWN = 10;
4
+ const when = at => Number.isFinite(at) ? new Date(at).toISOString().replace(/\.\d{3}Z$/, 'Z') : 'unknown time';
5
+ const reason = item => `${item.code ? `${item.code}: ` : ''}${item.message}`;
6
+
7
+ /** Human lines for a connector's sync status, each without a trailing newline; `indent` prefixes the detail lines. */
8
+ export function syncStatusLines(sync, { indent = ' ' } = {}) {
9
+ if (!sync) return [];
10
+ const lines = [];
11
+ const problems = sync.not_syncing ?? [];
12
+ if (problems.length) {
13
+ lines.push(`Not syncing (${problems.length} session${problems.length === 1 ? '' : 's'}, most recent first):`);
14
+ for (const item of problems.slice(0, SHOWN)) lines.push(`${indent}${item.harness} ${item.session_id}${item.rule_id ? ` · ${item.rule_id}` : ''} · ${when(item.at)} · ${reason(item)}`);
15
+ if (problems.length > SHOWN) lines.push(`${indent}… and ${problems.length - SHOWN} more (--json lists them all)`);
16
+ } else lines.push('Not syncing none');
17
+ if (sync.queued_captures) lines.push(`Queued ${sync.queued_captures} capture${sync.queued_captures === 1 ? '' : 's'} waiting for the server`);
18
+ if (sync.last_error) lines.push(`Last error ${when(sync.last_error.at)} · ${reason(sync.last_error)}`);
19
+ return lines;
20
+ }
package/bin/workspace.mjs CHANGED
@@ -9,7 +9,8 @@ import { WorkspaceContexts, fromContext, loginContext } from '../context.mjs';
9
9
  import { productionIds } from '@volter/teams/server/core';
10
10
  import { makeToken } from '@volter/teams/server/auth';
11
11
  import { moveWorkspace } from '../workspace-move.mjs';
12
- import { TeamsConnector } from '../connector/index.mjs';
12
+ import { TeamsConnector, readSyncStatus } from '../connector/index.mjs';
13
+ import { syncStatusLines } from './workspace/sync-status.mjs';
13
14
  import { CONTINUATION_FIDELITY, CONTINUATION_HARNESSES } from '../connector/native-continuation.mjs';
14
15
  import { TeamsMachine } from '../machine.mjs';
15
16
  import { teamsHome, paths } from '@volter/teams/home';
@@ -21,6 +22,7 @@ import { volterDeviceSignIn } from './workspace/volter-device.mjs';
21
22
  import { loginSummary, signInToTeams } from './workspace/volter-login.mjs';
22
23
  import { readSharedSignIn, signOut } from '@volter/identity';
23
24
  import { createInterface } from 'node:readline/promises';
25
+ import { connectRootScope, refuseBroadRoot } from './workspace/roots.mjs';
24
26
 
25
27
  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
28
 
@@ -204,7 +206,7 @@ async function authCommand({ flags, sub = 'status', rest, home, contexts, output
204
206
  if (account.context) {
205
207
  const command = authTeamCommand(home, account.context, flags, output, env);
206
208
  const enrollment = command.state().read();
207
- account.machine = enrollment?.machine ? { id: enrollment.machine.id, name: enrollment.machine.name, folder: enrollment.roots?.[0]?.path ?? null, service: enrollment.service?.kind ?? null, connection: null } : null;
209
+ account.machine = enrollment?.machine ? { id: enrollment.machine.id, name: enrollment.machine.name, folder: enrollment.roots?.[0]?.path ?? null, service: enrollment.service?.kind ?? null, connection: null, sync: readSyncStatus(command.state().directory) ?? { not_syncing: [], last_error: null, queued_captures: 0 } } : null;
208
210
  try { await command.client.me(); account.reachable = true; } catch { account.reachable = false; }
209
211
  if (account.machine && account.reachable) account.machine.connection = (await command.team.machines.get(account.machine.id).catch(() => null))?.data?.connection ?? null;
210
212
  }
@@ -213,6 +215,9 @@ async function authCommand({ flags, sub = 'status', rest, home, contexts, output
213
215
  if (!account.context) return say(account.others.length ? `Team none current · saved: ${account.others.join(', ')} (supercode auth switch NAME)` : 'Team none (supercode login)');
214
216
  say(`Team ${account.team} · ${new URL(account.origin).host}${account.reachable ? '' : ' · unreachable'}${account.others.length ? ` · also: ${account.others.join(', ')} (supercode auth switch)` : ''}`);
215
217
  say(account.machine ? `Machine ${account.machine.name} · ${account.machine.connection ?? 'unknown'} · sharing ${account.machine.folder ?? '?'}${account.machine.service ? ` (${account.machine.service})` : ' (runs while `teams connect` does)'}` : 'Machine not connected (supercode teams connect)');
218
+ // Only trouble (a session not syncing, an error in the last day) is worth lines here; `teams status` shows the rest.
219
+ const sync = account.machine?.sync;
220
+ if (sync?.not_syncing.length || sync?.last_error?.at > Date.now() - 86_400_000) for (const line of syncStatusLines(sync)) say(line);
216
221
  return;
217
222
  }
218
223
  if (sub === 'switch') {
@@ -607,6 +612,7 @@ async function syncCommand(sub, { flags, rest, output, client, context, team, ho
607
612
  if (!enrollment?.machine?.id) unsupported('this context has no connector enrollment');
608
613
  const path = realpathSync(resolve(flags.repo)), root = enrollment.roots?.find(item => item.path === path);
609
614
  if (!root) unsupported('the connector has not registered that repository root; restart connect with --cwd PATH');
615
+ refuseBroadRoot(path, { force: flags.force, verb: 'sync' });
610
616
  if (flags.harness && !root.harnesses.includes(flags.harness)) unsupported(`the native core does not report a ${flags.harness} discovery adapter`);
611
617
  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
618
  }
@@ -623,9 +629,26 @@ async function syncCommand(sub, { flags, rest, output, client, context, team, ho
623
629
 
624
630
  async function connectorCommand(verb, { flags, rest, output, client, context, team, contextName }, home) {
625
631
  requireArgs(rest, 0, 0, `teams ${verb}`); const state = new WorkspaceContexts(home).connector(context.server_id, context.team_id);
626
- 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 : [] } }); }
632
+ if (verb === 'status') {
633
+ let reachable = true; let account = null; try { account = await client.me(); } catch { reachable = false; }
634
+ const saved = state.read(); const running = saved?.process?.socket ? await activeSocket(saved.process.socket) : false;
635
+ // What the connector could not sync, as it last wrote it to its state (it is written while it runs).
636
+ const sync = saved ? readSyncStatus(state.directory) ?? { not_syncing: [], last_error: null, queued_captures: 0 } : null;
637
+ const value = { version: 1, context: scope(context), data: { reachable, account, connector: saved ? { enrolled: true, running, machine: saved.machine, source: saved.source, sync } : { enrolled: false, running: false }, sources: reachable ? (await team.sources.list()).items : [] } };
638
+ if (flags.json) return output(value);
639
+ const lines = [`Team ${context.team_name ?? context.team_id} · ${new URL(context.origin).host}${reachable ? '' : ' · unreachable'}`];
640
+ lines.push(saved?.machine ? `Connector ${saved.machine.name ?? saved.machine.id} · ${running ? 'running' : 'not running'}${saved.service?.kind ? ` (${saved.service.kind})` : ''}` : 'Connector not connected (supercode teams connect)');
641
+ lines.push(...syncStatusLines(sync));
642
+ process.stdout.write(`${lines.join('\n')}\n`);
643
+ return;
644
+ }
627
645
  if (verb === 'connect') {
628
646
  let saved = state.read();
647
+ const path = realpathSync(resolve(flags.cwd ?? '.'));
648
+ // A broad root is refused before enrolling anything. The service reruns connect without a terminal, and
649
+ // without --force, for the root accepted here; a broad root enrolled before rules covered subfolders keeps
650
+ // its exact-folder sync there.
651
+ const rootScope = connectRootScope(path, { force: flags.force, saved, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
629
652
  if (!saved) { const enrollment_key = crypto.randomUUID(); saved = { version: 1, enrollment_key, phase: 'enrolling' }; state.write(saved); }
630
653
  if (!saved.machine) {
631
654
  const response = await team.machines.create({ name: flags.name ?? hostname(), enrollment_key: saved.enrollment_key });
@@ -640,8 +663,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
640
663
  try { support = await core.supportReport(); } catch (error) { core.close(); throw error; }
641
664
  const harnesses = [...new Set((support.harnesses ?? []).map(item => item.id).filter(id => typeof id === 'string'))];
642
665
  if (!harnesses.length) { core.close(); unsupported('the native core reported no discoverable harness adapters'); }
643
- const path = realpathSync(resolve(flags.cwd ?? '.'));
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 };
666
+ 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
667
  saved = state.update(current => ({ ...current, roots: [root] }));
646
668
  // Run from a terminal, connect installs the service that keeps this machine connected (launchd or systemd) and
647
669
  // returns; the service itself runs connect without a terminal, in the foreground. --foreground runs it here.
@@ -676,6 +698,7 @@ async function connectorCommand(verb, { flags, rest, output, client, context, te
676
698
  const hostKeyId = publicKeyId(loadOrCreateKeyPair(paths(daemonHome).machineKey).publicKeyPem);
677
699
  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
700
  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() } });
701
+ 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
702
  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
703
  try { await connector.start({ roots: () => saved.roots }); await machine.start(); } catch (error) { await stopAll(); throw error; }
681
704
  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 } });
@@ -57,10 +57,17 @@ export interface ConnectorConfigurationValue {
57
57
  authorization_revision: number;
58
58
  }
59
59
 
60
+ export interface SyncErrorSummary { code: string | null; message: string; at: number }
61
+ export interface SyncProblem extends SyncErrorSummary { harness: string; session_id: string; rule_id: string | null }
62
+ /** What `supercode teams status` shows: sessions not syncing (most recent first) and the last error; null without state. */
63
+ export declare function readSyncStatus(stateDir: string): { not_syncing: SyncProblem[]; last_error: SyncErrorSummary | null; queued_captures: number } | null;
64
+
60
65
  export declare class TeamsConnector {
61
66
  constructor(options: TeamsConnectorOptions);
62
67
  readonly lastError?: unknown;
63
68
  configuration(): Promise<ConnectorConfigurationValue>;
69
+ /** Resolves when the first sync pass after `start` ends (rejects if it failed; the connector keeps running). `start` returns after the first heartbeat, without waiting for it. */
70
+ firstPass?: Promise<void> | null;
64
71
  start(options?: { bindings?: () => unknown | Promise<unknown>; roots?: () => Array<{ id: string; machine_id: string; name: string; harnesses: HarnessId[]; path?: string; workspace?: string; homes?: unknown }> | Promise<Array<{ id: string; machine_id: string; name: string; harnesses: HarnessId[]; path?: string; workspace?: string; homes?: unknown }>> }): Promise<ConnectorConfigurationValue>;
65
72
  capture(options: CaptureOptions): Promise<CaptureReceipt | null>;
66
73
  flush(): Promise<CaptureReceipt | null>;
@@ -5,6 +5,39 @@ import { canonicalJson } from '../contracts/index.mjs';
5
5
  import { EffectConsumer } from './effects.mjs';
6
6
  import { NativeEffects } from './native-effects.mjs';
7
7
 
8
+ const SYNC_PROBLEM_LIMIT=20;
9
+ const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='string'?error.code:null,message:String(error.message??error).slice(0,500),at};
10
+ /** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
11
+ export function readSyncStatus(stateDir){
12
+ let state;try{state=JSON.parse(readFileSync(join(stateDir,'connector.json'),'utf8'));}catch{return null;}
13
+ const status=state?.sync_status;
14
+ return {not_syncing:Array.isArray(status?.problems)?status.problems:[],last_error:status?.last_error??null,queued_captures:Array.isArray(state?.outbox)?state.outbox.length:0};
15
+ }
16
+ // A tool call still running when the core reads a session has no result yet; the core answers it with a synthesized
17
+ // placeholder (crates/interchange session/helpers.rs ensure_tool_results_paired: a `tool` message for that call id,
18
+ // right after the assistant message that made the call, marked `sc.tool_error`, whose only distinct trait is its
19
+ // text), and the real result replaces it at the same position once it is written. So a `none` rule that first sees
20
+ // a session mid tool call keeps back history whose last positions will change. Rather than match the placeholder's
21
+ // text, the kept-back history is identified by its shape: the trailing run of tool results and the call ids they
22
+ // answer (recorded when the rule starts). A later rewrite confined to that run, answering the same calls of the
23
+ // unchanged assistant message before it, is the same history settling: it stays kept back and is never published.
24
+ const trailingToolResults=messages=>{
25
+ let from=messages.length;while(from>0&&messages[from-1].value?.role==='tool'&&typeof messages[from-1].value.tool_call_id==='string')from--;
26
+ return from===messages.length||from===0?null:{from,call_ids:messages.slice(from).map(message=>message.value.tool_call_id)};
27
+ };
28
+ // The kept-back digests after such a settled rewrite, or null when the kept-back history changed in any other way.
29
+ // A baseline recorded before its tool tail was (baseline_tool_tail absent) takes the tail from the current messages.
30
+ function settledToolTail(previous,messages){
31
+ const baseline=previous.baseline,count=baseline.length,digestOf=message=>hash(canonicalJson(message.value));
32
+ if(messages.length<count||baseline.some((native_key,index)=>messages[index].native_key!==native_key))return null;
33
+ const tail=previous.baseline_tool_tail??trailingToolResults(messages.slice(0,count));
34
+ if(!tail||!Number.isSafeInteger(tail.from)||tail.from<1||tail.from+tail.call_ids.length!==count)return null;
35
+ for(let index=0;index<tail.from;index++)if(digestOf(messages[index])!==previous.baseline_digests?.[index])return null;
36
+ const run=messages.slice(tail.from,count),ids=run.map(message=>message.value?.role==='tool'?message.value.tool_call_id:null);
37
+ const calls=new Set((messages[tail.from-1].value?.tool_calls??[]).map(call=>call?.id).filter(id=>typeof id==='string'));
38
+ if(ids.some(id=>typeof id!=='string'||!calls.has(id))||new Set(ids).size!==ids.length||[...ids].sort().join('\0')!==[...tail.call_ids].sort().join('\0'))return null;
39
+ return messages.slice(0,count).map(digestOf);
40
+ }
8
41
  const hash=value=>createHash('sha256').update(value).digest('hex');
9
42
  const recordedAt=entry=>{if(Number.isSafeInteger(entry.recorded_at))return entry.recorded_at;if(Number.isSafeInteger(entry.timestamp))return entry.timestamp;const value=entry.metadata?.timestamp??entry.value?.metadata?.timestamp??entry.timestamp;if(typeof value!=='string')return null;const parsed=Date.parse(value);return Number.isSafeInteger(parsed)?parsed:null;};
10
43
  const serverOrigin=value=>{const url=new URL(value),loopback=url.hostname==='localhost'||url.hostname==='::1'||url.hostname==='[::1]'||/^127(?:\.\d{1,3}){3}$/.test(url.hostname);if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.search||url.hash||(url.pathname!==''&&url.pathname!=='/')||(url.protocol==='http:'&&!loopback))throw new TypeError('server must be an HTTPS origin (HTTP is loopback-only)');return url.origin;};
@@ -25,34 +58,81 @@ function atomic(path, value) {
25
58
  }
26
59
  export class TeamsConnector {
27
60
  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);const 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});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;}
29
- async configuration(){const value=await this.request('GET',`/machines/${this.machineId}/configuration`);this.configurationValue=value;return value;}
61
+ save(){atomic(this.statePath,this.state);}
62
+ // What `supercode teams status` shows about this connector: its last error, and the sessions it could not capture
63
+ // (most recent first, bounded; an entry clears when that session next captures). They are kept in connector.json,
64
+ // so another process reads them while this one runs; the error itself stays in memory as lastError.
65
+ get lastError(){return this.lastErrorValue;}
66
+ set lastError(error){this.lastErrorValue=error;this.syncStatus().last_error=errorSummary(error,this.clock.nowMs());this.saveQuietly();}
67
+ syncStatus(){const status=this.state.sync_status??={problems:[],last_error:null};status.problems??=[];return status;}
68
+ saveQuietly(){try{this.save();}catch{}}
69
+ noteSessionProblem({harness,nativeSessionId,ruleId=null},error){
70
+ const status=this.syncStatus(),at=this.clock.nowMs();
71
+ status.problems=[{harness,session_id:nativeSessionId,rule_id:ruleId,...errorSummary(error,at)},...status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId))].slice(0,SYNC_PROBLEM_LIMIT);
72
+ this.lastError=error;
73
+ }
74
+ clearSessionProblem(harness,nativeSessionId){
75
+ const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
76
+ status.problems=status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId));this.saveQuietly();
77
+ } 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});
78
+ // A rate limit is waited out (the server says how long; the same idempotency key makes the retry safe), so a
79
+ // large first sync slows down instead of failing its pass and restarting the connector.
80
+ 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.
81
+ 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;
82
+ 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;}
83
+ async configuration(){const value=await this.request('GET',`/machines/${this.machineId}/configuration`);this.configurationValue=value;this.pruneStamps(value);return value;}
84
+ // A capture stamp belongs to one rule; once that rule is no longer active its stamps are dropped.
85
+ pruneStamps(configuration){
86
+ if(!Array.isArray(configuration?.rules))return;
87
+ const active=new Set(configuration.rules.filter(rule=>rule.state==='active').map(rule=>rule.id));let pruned=false;
88
+ 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;}
89
+ // A session that stopped syncing under a rule no longer active is no longer that rule's problem.
90
+ const problems=this.state.sync_status?.problems;
91
+ if(problems?.some(item=>item.rule_id&&!active.has(item.rule_id))){this.state.sync_status.problems=problems.filter(item=>!item.rule_id||active.has(item.rule_id));pruned=true;}
92
+ if(pruned)this.save();
93
+ }
30
94
  async start({bindings=()=>[],roots=()=>[]}={}) {
31
95
  await this.configuration();
32
- const heartbeat = async () => {
33
- this.state.sequence += 1;
34
- await this.request('POST', `/machines/${this.machineId}/heartbeat`, {
35
- incarnation_id: this.state.incarnation_id, sequence: this.state.sequence,
36
- bindings: [...this.nativeEffects.published(), ...await bindings()],
37
- roots: this.localRoots.map(({id,machine_id,name,harnesses})=>({id,machine_id,name,harnesses})),
38
- ...(this.hostKeyId ? { host_key_id: this.hostKeyId } : {}),
96
+ // Heartbeats are serialized (the server takes each sequence number once) and run on their own
97
+ // timer: a long sync pass (a large first capture) never makes a live machine read as offline.
98
+ let beating = Promise.resolve();
99
+ const heartbeat = () => {
100
+ const next = beating.then(async () => {
101
+ this.state.sequence += 1;
102
+ await this.request('POST', `/machines/${this.machineId}/heartbeat`, {
103
+ incarnation_id: this.state.incarnation_id, sequence: this.state.sequence,
104
+ bindings: [...this.nativeEffects.published(), ...await bindings()],
105
+ roots: this.localRoots.map(({id,machine_id,name,harnesses})=>({id,machine_id,name,harnesses})),
106
+ ...(this.hostKeyId ? { host_key_id: this.hostKeyId } : {}),
107
+ });
108
+ this.save();
39
109
  });
40
- this.save();
110
+ beating = next.catch(() => {});
111
+ return next;
41
112
  };
42
- const beat = async () => {
43
- this.localRoots = await roots();
44
- await heartbeat();
113
+ const pass = async () => {
45
114
  const configuration = await this.configuration();
46
115
  await this.syncConfiguredSessions(configuration, this.localRoots);
47
116
  await heartbeat();
48
117
  await this.pollEffects();
49
118
  };
50
- await beat();
119
+ this.localRoots = await roots();
120
+ await heartbeat();
121
+ // The first sync pass runs behind `start`, so the machine's link opens without waiting for it.
51
122
  let running = false;
52
- this.timer = setInterval(() => {
53
- if (running || this.abort.signal.aborted) return;
123
+ const syncPass = () => {
124
+ if (running || this.abort.signal.aborted) return null;
54
125
  running = true;
55
- void beat().catch(error => { this.lastError = error; }).finally(() => { running = false; });
126
+ const run = (async () => { this.localRoots = await roots(); await pass(); })();
127
+ void run.catch(error => { this.lastError = error; }).finally(() => { running = false; });
128
+ return run;
129
+ };
130
+ /** The first sync pass, for a caller that waits on it; the connector keeps running if it fails. */
131
+ this.firstPass = syncPass();
132
+ this.timer = setInterval(syncPass, 15000);
133
+ this.heartbeatTimer = setInterval(() => {
134
+ if (this.abort.signal.aborted) return;
135
+ void heartbeat().catch(error => { this.lastError = error; });
56
136
  }, 15000);
57
137
  return this.configurationValue;
58
138
  }
@@ -82,28 +162,39 @@ export class TeamsConnector {
82
162
  this.nativeEffects.beginDiscovery();
83
163
  const activeRules=new Map(configuration.rules.filter(item=>item.state==='active').map(rule=>[rule.id,rule]));
84
164
  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);}}
165
+ // A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
166
+ // reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
167
+ // machine; rules over the same root and harnesses share the scan.
168
+ const discoveries=new Map();
85
169
  for(const rule of activeRules.values()){
86
170
  const root=roots.find(item=>item.id===rule.selector.root_id);
87
171
  if(!root)continue;
88
- const descriptors=[];
89
- let cursor;
90
- do {
91
- const page=await this.core.discover({workspace:root.path??root.workspace,harnesses:rule.selector.harnesses,homes:root.homes,limit:2048,...(cursor?{cursor}:{})});
92
- descriptors.push(...(page.sessions??page.entries??[]));
93
- cursor=page.next_cursor??page.nextCursor??null;
94
- } while(cursor);
172
+ const workspace=root.path??root.workspace,subtree=!root.exact_only,discoveryKey=JSON.stringify([workspace,subtree,[...(rule.selector.harnesses??[])].sort(),root.homes??null]);
173
+ if(!discoveries.has(discoveryKey))discoveries.set(discoveryKey,(async()=>{
174
+ const descriptors=[];
175
+ let cursor;
176
+ do {
177
+ // A broad root enrolled before rules covered subfolders keeps its exact-folder discovery.
178
+ const page=await this.core.discover({workspace,...(subtree?{workspace_subtree:true}:{}),harnesses:rule.selector.harnesses,homes:root.homes,limit:2048,...(cursor?{cursor}:{})});
179
+ descriptors.push(...(page.sessions??page.entries??[]));
180
+ cursor=page.next_cursor??page.nextCursor??null;
181
+ } while(cursor);
182
+ return descriptors;
183
+ })());
184
+ const descriptors=await discoveries.get(discoveryKey);
95
185
  for(const descriptor of descriptors){
96
186
  const locator=descriptor.locator,nativeSessionId=locator?.session_id,harness=locator?.harness;
97
187
  if(!locator||!nativeSessionId||!harness)continue;
98
- const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:root.path??root.workspace};
188
+ const options={locator,descriptor,harness,nativeSessionId,ruleId:rule.id,ruleRevision:rule.revision,backfill:rule.backfill,rootPath:workspace,configuration};
189
+ // A session whose native store has not changed since this rule last captured it is not reloaded.
99
190
  // One session that cannot be captured (a transcript that rewrote its history needs an explicit resync)
100
191
  // is recorded, logged and skipped; it must not stop every other session, or the connector itself, from
101
192
  // syncing. An error of the connection itself (the outbox, authentication, the server, a stop) still ends
102
193
  // the pass. The session keeps its native binding either way, so it can still be attached to.
103
194
  let captured = true;
104
- try { await this.capture(options); } catch (error) {
195
+ if (this.sessionChanged(options)) try { await this.capture(options); } catch (error) {
105
196
  if (error.outbox || error.status === 401 || error.status === 403 || error.status >= 500 || this.abort.signal.aborted) throw error;
106
- captured = false; this.lastError = error;
197
+ captured = false; this.noteSessionProblem(options, error);
107
198
  process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
108
199
  }
109
200
  try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
@@ -111,42 +202,86 @@ export class TeamsConnector {
111
202
  const key=`${rule.id}:${harness}:${nativeSessionId}`;
112
203
  if(this.followers.has(key)||typeof this.core.session(locator).follow!=='function')continue;
113
204
  const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
114
- const task=(async()=>{for await(const _event of this.core.session(locator).follow({signal:controller.signal})){if(controller.signal.aborted)break;await this.capture(options);}})().catch(error=>{if(!controller.signal.aborted)this.lastError=error;}).finally(()=>{this.abort.signal.removeEventListener('abort',stop);this.followers.delete(key);});
205
+ // A follow event may land between beats, after the rule changed: it reads the configuration afresh.
206
+ const followed={...options,configuration:null,stamp:undefined};
207
+ 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.noteSessionProblem(followed,error);}).finally(()=>{this.abort.signal.removeEventListener('abort',stop);this.followers.delete(key);});
115
208
  this.followers.set(key,{ruleId:rule.id,ruleRevision:rule.revision,controller,task});
116
209
  }
117
210
  }
118
211
  this.nativeEffects.endDiscovery();
119
212
  }
213
+ // What a session's native store looked like when discovery listed it: its file's size and modification
214
+ // time (read before any load, so a write after it reads as a change), those of the file's SQLite write-ahead
215
+ // log, and the descriptor's updated_at_ms and message_count. A SQLite store (Hermes state.db, OpenCode,
216
+ // Goose) is shared by many sessions, and a live one appends to `<path>-wal` until a checkpoint while the
217
+ // database file, and a Hermes session's updated_at_ms (its start until it ends), stay put; its message count
218
+ // still moves. Null when nothing is known; such a session is reloaded every beat.
219
+ sessionStamp({locator,descriptor}){
220
+ const stat=path=>{try{const value=statSync(path);return `${value.size}:${value.mtimeMs}`;}catch{return null;}};
221
+ const path=typeof locator?.storage?.path==='string'?locator.storage.path:null;
222
+ const file=path?stat(path):null,wal=path?stat(`${path}-wal`):null;
223
+ const updated=Number.isSafeInteger(descriptor?.updated_at_ms)?descriptor.updated_at_ms:null;
224
+ const count=Number.isSafeInteger(descriptor?.message_count)?descriptor.message_count:null;
225
+ return file===null&&updated===null&&count===null?null:JSON.stringify([file,wal,updated,count]);
226
+ }
227
+ sessionChanged(options){
228
+ const stamp=this.sessionStamp(options);options.stamp=stamp;
229
+ if(stamp===null||this.state.outbox.length)return true;
230
+ const rule=options.configuration?.rules?.find(item=>item.id===options.ruleId&&item.revision===options.ruleRevision&&item.state==='active');if(!rule)return true;
231
+ const configured=rule.backfill??options.backfill??'all',mode=typeof configured==='string'?configured:configured.mode;
232
+ const seen=this.state.origins[`${options.harness}:${options.nativeSessionId}`]?.capture_stamps?.[options.ruleId];
233
+ return !(seen&&seen.stamp===stamp&&seen.rule_revision===options.ruleRevision&&seen.backfill_mode===mode);
234
+ }
235
+ recordStamp(key,ruleId,ruleRevision,backfillMode,stamp){
236
+ const origin=this.state.origins[key];
237
+ if(origin&&ruleId&&stamp)origin.capture_stamps={...origin.capture_stamps,[ruleId]:{stamp,rule_revision:ruleRevision,backfill_mode:backfillMode}};
238
+ this.save();
239
+ }
120
240
  capture(options) {
121
- const work=this.work.then(()=>this.captureSerial(options));
241
+ const work=this.work.then(()=>this.captureSerial(options)).then(receipt=>{this.clearSessionProblem(options.harness,options.nativeSessionId);return receipt;});
122
242
  this.work=work.catch(()=>{});
123
243
  return work;
124
244
  }
125
- async captureSerial({locator,descriptor=null,harness,nativeSessionId,visibility='restricted',ruleId=null,ruleRevision=null,coverage={completeness:'complete',reasons:[],missing:[]},backfill='all',rootPath=null}) {
245
+ 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
246
  if(this.state.outbox.length)await this.flushSerial();
127
- const configuration=await this.configuration(),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;
247
+ // Discovery hands each session the configuration its beat fetched; a follow event fetches its own.
248
+ if(stamp===undefined)stamp=this.sessionStamp({locator,descriptor});
249
+ 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
250
  const artifactSource=locator?.storage?.kind==='file'&&existsSync(locator.storage.path)?realpathSync(locator.storage.path):null;
129
251
  const artifactBefore=artifactSource?statSync(artifactSource):null;
130
252
  const loaded=await this.core.session(locator).load(),configuredBackfill=rule.backfill??backfill??'all',backfillPolicy=typeof configuredBackfill==='string'?{mode:configuredBackfill,since:null}:configuredBackfill;
131
253
  const key=`${harness}:${nativeSessionId}`,previous=this.state.origins[key]??null;
132
254
  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.save();return null;}
255
+ 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;}
256
+ let effectivePrevious=previous;const clearBaseline=backfillPolicy.mode==='all';
257
+ if(!previous&&backfillPolicy.mode==='none'){const toolTail=trailingToolResults(messages);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))),...(toolTail?{baseline_tool_tail:toolTail}:{}),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
258
  if(!previous&&backfillPolicy.mode==='since'){
137
259
  if(!Number.isSafeInteger(backfillPolicy.since))throw Object.assign(new Error('since backfill requires an integer timestamp'),{code:'invalid_request'});
138
260
  const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
139
261
  effectivePrevious={generation:1,sequence:0,digest:null,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
140
262
  this.state.origins[key]=effectivePrevious;
141
263
  }
142
- if(previous?.observed_keys?.every(x=>/^\d+$/.test(x))){
143
- const prefix=messages.slice(0,previous.observed_keys.length);
144
- const rewritten=prefix.length!==previous.observed_keys.length||prefix.some((message,index)=>message.native_key!==previous.observed_keys[index]||hash(canonicalJson(message.value))!==previous.observed_digests?.[index]);
145
- if(rewritten)throw Object.assign(new Error('index-key source rewrote its baseline; explicit backfill is required'),{code:'resync_required'});
264
+ // A source keyed by position (a Claude Code transcript is) can change a message it already reported: the core
265
+ // reports a tool call still running as an interrupted placeholder, and the result replaces it at the same
266
+ // position; a compaction or a parser change rewrites more. Every capture is a whole snapshot (the server keeps
267
+ // only the latest capture's messages as the session's history), so a rewrite of what was published is simply
268
+ // republished by the next capture. Only the unpublished baseline of a `none` or `since` rule is a problem:
269
+ // positions no longer identify the history the rule kept back. A `since` rule derives it again from the
270
+ // timestamps; a `none` rule cannot tell old history from new, so that session waits for an explicit
271
+ // `supercode teams sync resume RULE --backfill all` (or a since window), which republishes it whole.
272
+ if(!clearBaseline&&effectivePrevious?.baseline?.length&&effectivePrevious.baseline.every(x=>/^\d+$/.test(x))){
273
+ let baselineMoved=effectivePrevious.baseline.some((native_key,index)=>messages[index]?.native_key!==native_key||hash(canonicalJson(messages[index].value))!==effectivePrevious.baseline_digests?.[index]);
274
+ // A rule that started mid tool call kept back the core's placeholder result; the real result replaces it.
275
+ const settled=baselineMoved&&backfillPolicy.mode==='none'?settledToolTail(effectivePrevious,messages):null;
276
+ if(settled){baselineMoved=false;effectivePrevious={...effectivePrevious,baseline_digests:settled};this.state.origins[key]={...this.state.origins[key],baseline_digests:settled};this.save();}
277
+ if(baselineMoved&&backfillPolicy.mode==='since'&&Number.isSafeInteger(backfillPolicy.since)){
278
+ const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
279
+ effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
280
+ this.state.origins[key]={...this.state.origins[key],baseline:effectivePrevious.baseline,baseline_digests:effectivePrevious.baseline_digests};
281
+ }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
282
  }
147
- const clearBaseline=backfillPolicy.mode==='all';
148
283
  if(effectivePrevious?.baseline&&!clearBaseline)messages=messages.filter(x=>!effectivePrevious.baseline.includes(x.native_key));
149
- if(!messages.length)return null;
284
+ if(!messages.length){this.recordStamp(key,ruleId,ruleRevision,backfillPolicy.mode,stamp);return null;}
150
285
  const captureId=`cap_${randomBytes(16).toString('hex')}`,generation=effectivePrevious?.generation??1,sequence=(effectivePrevious?.sequence??0)+1;
151
286
  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
287
  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 +307,7 @@ export class TeamsConnector {
172
307
  }
173
308
  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
309
  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();
310
+ 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
311
  }
177
312
  flush() {
178
313
  const work=this.work.then(()=>this.flushSerial());
@@ -181,6 +316,18 @@ export class TeamsConnector {
181
316
  }
182
317
  // Every outbox error is the connection's, not one session's: a capture's own catch passes it on (syncConfiguredSessions).
183
318
  async flushSerial(){try{return await this.flushOutbox();}catch(error){throw Object.assign(error,{outbox:true});}}
184
- async flushOutbox(){let latest=null;while(this.state.outbox.length){const item=this.state.outbox[0];const begun=await this.request('POST',`/sources/${this.sourceId}/captures`,item.begin);let receipt;if(begun.state==='committed')receipt=begun.receipt;else{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);receipt=await this.request('POST',`/sources/${this.sourceId}/captures/${item.begin.capture_id}/commit`,item.commit);}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;}this.state.origins[key]=next;this.state.outbox.shift();this.save();latest=receipt;}return latest;}
185
- stop(){if(this.timer)clearInterval(this.timer);this.abort.abort();for(const follower of this.followers?.values()??[])follower.controller.abort();return this.nativeEffects.close();}
319
+ 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);}
320
+ // True when the server refused a queued capture because its sync rule was removed, paused or revised since it
321
+ // was queued: the capture can never be accepted, so it must not hold every later capture behind it. The
322
+ // refusal is confirmed against a fresh configuration; any other refusal (a revoked credential, a paused
323
+ // source) or a network or server error keeps the capture queued for the next flush.
324
+ async ruleRejected(item,error){
325
+ if(!item.begin.rule_id||!(error.status===403||error.status===404||error.code==='source_paused'))return false;
326
+ let configuration;try{configuration=await this.configuration();}catch{return false;}
327
+ // A configuration without a rule list says nothing about the rule, so the capture stays queued.
328
+ if(!Array.isArray(configuration?.rules))return false;
329
+ return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
330
+ }
331
+ 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;delete next.baseline_tool_tail;}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;}
332
+ 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();}
186
333
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.21",
3
+ "version": "0.2.23",
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.7",
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",