@volter/supercode-teams 0.2.22 → 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,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';
@@ -205,7 +206,7 @@ async function authCommand({ flags, sub = 'status', rest, home, contexts, output
205
206
  if (account.context) {
206
207
  const command = authTeamCommand(home, account.context, flags, output, env);
207
208
  const enrollment = command.state().read();
208
- 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;
209
210
  try { await command.client.me(); account.reachable = true; } catch { account.reachable = false; }
210
211
  if (account.machine && account.reachable) account.machine.connection = (await command.team.machines.get(account.machine.id).catch(() => null))?.data?.connection ?? null;
211
212
  }
@@ -214,6 +215,9 @@ async function authCommand({ flags, sub = 'status', rest, home, contexts, output
214
215
  if (!account.context) return say(account.others.length ? `Team none current · saved: ${account.others.join(', ')} (supercode auth switch NAME)` : 'Team none (supercode login)');
215
216
  say(`Team ${account.team} · ${new URL(account.origin).host}${account.reachable ? '' : ' · unreachable'}${account.others.length ? ` · also: ${account.others.join(', ')} (supercode auth switch)` : ''}`);
216
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);
217
221
  return;
218
222
  }
219
223
  if (sub === 'switch') {
@@ -625,7 +629,19 @@ async function syncCommand(sub, { flags, rest, output, client, context, team, ho
625
629
 
626
630
  async function connectorCommand(verb, { flags, rest, output, client, context, team, contextName }, home) {
627
631
  requireArgs(rest, 0, 0, `teams ${verb}`); const state = new WorkspaceContexts(home).connector(context.server_id, context.team_id);
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 : [] } }); }
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
+ }
629
645
  if (verb === 'connect') {
630
646
  let saved = state.read();
631
647
  const path = realpathSync(resolve(flags.cwd ?? '.'));
@@ -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,7 +58,23 @@ 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);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});
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});
29
78
  // A rate limit is waited out (the server says how long; the same idempotency key makes the retry safe), so a
30
79
  // large first sync slows down instead of failing its pass and restarting the connector.
31
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.
@@ -37,34 +86,53 @@ export class TeamsConnector {
37
86
  if(!Array.isArray(configuration?.rules))return;
38
87
  const active=new Set(configuration.rules.filter(rule=>rule.state==='active').map(rule=>rule.id));let pruned=false;
39
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;}
40
92
  if(pruned)this.save();
41
93
  }
42
94
  async start({bindings=()=>[],roots=()=>[]}={}) {
43
95
  await this.configuration();
44
- const heartbeat = async () => {
45
- this.state.sequence += 1;
46
- await this.request('POST', `/machines/${this.machineId}/heartbeat`, {
47
- incarnation_id: this.state.incarnation_id, sequence: this.state.sequence,
48
- bindings: [...this.nativeEffects.published(), ...await bindings()],
49
- roots: this.localRoots.map(({id,machine_id,name,harnesses})=>({id,machine_id,name,harnesses})),
50
- ...(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();
51
109
  });
52
- this.save();
110
+ beating = next.catch(() => {});
111
+ return next;
53
112
  };
54
- const beat = async () => {
55
- this.localRoots = await roots();
56
- await heartbeat();
113
+ const pass = async () => {
57
114
  const configuration = await this.configuration();
58
115
  await this.syncConfiguredSessions(configuration, this.localRoots);
59
116
  await heartbeat();
60
117
  await this.pollEffects();
61
118
  };
62
- 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.
63
122
  let running = false;
64
- this.timer = setInterval(() => {
65
- if (running || this.abort.signal.aborted) return;
123
+ const syncPass = () => {
124
+ if (running || this.abort.signal.aborted) return null;
66
125
  running = true;
67
- 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; });
68
136
  }, 15000);
69
137
  return this.configurationValue;
70
138
  }
@@ -126,7 +194,7 @@ export class TeamsConnector {
126
194
  let captured = true;
127
195
  if (this.sessionChanged(options)) try { await this.capture(options); } catch (error) {
128
196
  if (error.outbox || error.status === 401 || error.status === 403 || error.status >= 500 || this.abort.signal.aborted) throw error;
129
- captured = false; this.lastError = error;
197
+ captured = false; this.noteSessionProblem(options, error);
130
198
  process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
131
199
  }
132
200
  try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
@@ -136,7 +204,7 @@ export class TeamsConnector {
136
204
  const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
137
205
  // A follow event may land between beats, after the rule changed: it reads the configuration afresh.
138
206
  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);});
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);});
140
208
  this.followers.set(key,{ruleId:rule.id,ruleRevision:rule.revision,controller,task});
141
209
  }
142
210
  }
@@ -170,7 +238,7 @@ export class TeamsConnector {
170
238
  this.save();
171
239
  }
172
240
  capture(options) {
173
- 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;});
174
242
  this.work=work.catch(()=>{});
175
243
  return work;
176
244
  }
@@ -186,7 +254,7 @@ export class TeamsConnector {
186
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};});
187
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;}
188
256
  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;}
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;}
190
258
  if(!previous&&backfillPolicy.mode==='since'){
191
259
  if(!Number.isSafeInteger(backfillPolicy.since))throw Object.assign(new Error('since backfill requires an integer timestamp'),{code:'invalid_request'});
192
260
  const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
@@ -202,7 +270,10 @@ export class TeamsConnector {
202
270
  // timestamps; a `none` rule cannot tell old history from new, so that session waits for an explicit
203
271
  // `supercode teams sync resume RULE --backfill all` (or a since window), which republishes it whole.
204
272
  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]);
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();}
206
277
  if(baselineMoved&&backfillPolicy.mode==='since'&&Number.isSafeInteger(backfillPolicy.since)){
207
278
  const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
208
279
  effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
@@ -257,6 +328,6 @@ export class TeamsConnector {
257
328
  if(!Array.isArray(configuration?.rules))return false;
258
329
  return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
259
330
  }
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;}
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();}
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();}
262
333
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.22",
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",