@volter/supercode-teams 0.2.22 → 0.2.24
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/sync-status.mjs +20 -0
- package/bin/workspace.mjs +19 -3
- package/connector/index.d.ts +7 -0
- package/connector/index.mjs +158 -34
- package/package.json +1 -1
|
@@ -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') {
|
|
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 ?? '.'));
|
package/connector/index.d.ts
CHANGED
|
@@ -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>;
|
package/connector/index.mjs
CHANGED
|
@@ -5,6 +5,49 @@ 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
|
+
// Live follows are bounded. The harness SDK lets at most 64 subscription opens await replies at once (sdk/typescript
|
|
10
|
+
// core.mjs MAX_PENDING_RUNTIME_OPENS; more reject with SUBSCRIPTION_OPEN_CAPACITY) and every live follow is a follower
|
|
11
|
+
// the service polls, while a subtree rule can cover hundreds of sessions. Only the most recently active sessions (at
|
|
12
|
+
// most FOLLOW_LIMIT, half the SDK's limit for headroom, active within FOLLOW_ACTIVE_WINDOW_MS) are followed live; the
|
|
13
|
+
// rest are covered by each beat's stamp-based capture. Followers rotate as activity changes.
|
|
14
|
+
const FOLLOW_LIMIT=32,FOLLOW_ACTIVE_WINDOW_MS=30*60*1000,FOLLOW_RETRY_MS=60*1000;
|
|
15
|
+
const CAPACITY_CODE='SUBSCRIPTION_OPEN_CAPACITY';
|
|
16
|
+
const capacityError=error=>error?.code===CAPACITY_CODE||/Too many unresolved subscriptions/.test(String(error?.message??''));
|
|
17
|
+
// An error of the connection itself (the outbox, authentication, the server), not of one session.
|
|
18
|
+
const connectionError=error=>Boolean(error?.outbox||error?.status===401||error?.status===403||error?.status>=500);
|
|
19
|
+
const errorSummary=(error,at)=>error==null?null:{code:typeof error.code==='string'?error.code:null,message:String(error.message??error).slice(0,500),at};
|
|
20
|
+
/** The sync status a connector keeps in its state directory (see TeamsConnector#syncStatus); null when it has none. */
|
|
21
|
+
export function readSyncStatus(stateDir){
|
|
22
|
+
let state;try{state=JSON.parse(readFileSync(join(stateDir,'connector.json'),'utf8'));}catch{return null;}
|
|
23
|
+
const status=state?.sync_status;
|
|
24
|
+
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};
|
|
25
|
+
}
|
|
26
|
+
// A tool call still running when the core reads a session has no result yet; the core answers it with a synthesized
|
|
27
|
+
// placeholder (crates/interchange session/helpers.rs ensure_tool_results_paired: a `tool` message for that call id,
|
|
28
|
+
// right after the assistant message that made the call, marked `sc.tool_error`, whose only distinct trait is its
|
|
29
|
+
// text), and the real result replaces it at the same position once it is written. So a `none` rule that first sees
|
|
30
|
+
// a session mid tool call keeps back history whose last positions will change. Rather than match the placeholder's
|
|
31
|
+
// text, the kept-back history is identified by its shape: the trailing run of tool results and the call ids they
|
|
32
|
+
// answer (recorded when the rule starts). A later rewrite confined to that run, answering the same calls of the
|
|
33
|
+
// unchanged assistant message before it, is the same history settling: it stays kept back and is never published.
|
|
34
|
+
const trailingToolResults=messages=>{
|
|
35
|
+
let from=messages.length;while(from>0&&messages[from-1].value?.role==='tool'&&typeof messages[from-1].value.tool_call_id==='string')from--;
|
|
36
|
+
return from===messages.length||from===0?null:{from,call_ids:messages.slice(from).map(message=>message.value.tool_call_id)};
|
|
37
|
+
};
|
|
38
|
+
// The kept-back digests after such a settled rewrite, or null when the kept-back history changed in any other way.
|
|
39
|
+
// A baseline recorded before its tool tail was (baseline_tool_tail absent) takes the tail from the current messages.
|
|
40
|
+
function settledToolTail(previous,messages){
|
|
41
|
+
const baseline=previous.baseline,count=baseline.length,digestOf=message=>hash(canonicalJson(message.value));
|
|
42
|
+
if(messages.length<count||baseline.some((native_key,index)=>messages[index].native_key!==native_key))return null;
|
|
43
|
+
const tail=previous.baseline_tool_tail??trailingToolResults(messages.slice(0,count));
|
|
44
|
+
if(!tail||!Number.isSafeInteger(tail.from)||tail.from<1||tail.from+tail.call_ids.length!==count)return null;
|
|
45
|
+
for(let index=0;index<tail.from;index++)if(digestOf(messages[index])!==previous.baseline_digests?.[index])return null;
|
|
46
|
+
const run=messages.slice(tail.from,count),ids=run.map(message=>message.value?.role==='tool'?message.value.tool_call_id:null);
|
|
47
|
+
const calls=new Set((messages[tail.from-1].value?.tool_calls??[]).map(call=>call?.id).filter(id=>typeof id==='string'));
|
|
48
|
+
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;
|
|
49
|
+
return messages.slice(0,count).map(digestOf);
|
|
50
|
+
}
|
|
8
51
|
const hash=value=>createHash('sha256').update(value).digest('hex');
|
|
9
52
|
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
53
|
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;};
|
|
@@ -24,8 +67,24 @@ function atomic(path, value) {
|
|
|
24
67
|
}
|
|
25
68
|
}
|
|
26
69
|
export class TeamsConnector {
|
|
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);}
|
|
70
|
+
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??={};if(Array.isArray(this.state.sync_status?.problems))this.state.sync_status.problems=this.state.sync_status.problems.filter(item=>item?.code!==CAPACITY_CODE);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();}
|
|
71
|
+
save(){atomic(this.statePath,this.state);}
|
|
72
|
+
// What `supercode teams status` shows about this connector: its last error, and the sessions it could not capture
|
|
73
|
+
// (most recent first, bounded; an entry clears when that session next captures). They are kept in connector.json,
|
|
74
|
+
// so another process reads them while this one runs; the error itself stays in memory as lastError.
|
|
75
|
+
get lastError(){return this.lastErrorValue;}
|
|
76
|
+
set lastError(error){this.lastErrorValue=error;this.syncStatus().last_error=errorSummary(error,this.clock.nowMs());this.saveQuietly();}
|
|
77
|
+
syncStatus(){const status=this.state.sync_status??={problems:[],last_error:null};status.problems??=[];return status;}
|
|
78
|
+
saveQuietly(){try{this.save();}catch{}}
|
|
79
|
+
noteSessionProblem({harness,nativeSessionId,ruleId=null},error){
|
|
80
|
+
const status=this.syncStatus(),at=this.clock.nowMs();
|
|
81
|
+
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);
|
|
82
|
+
this.lastError=error;
|
|
83
|
+
}
|
|
84
|
+
clearSessionProblem(harness,nativeSessionId){
|
|
85
|
+
const status=this.state.sync_status;if(!status?.problems?.some(item=>item.harness===harness&&item.session_id===nativeSessionId))return;
|
|
86
|
+
status.problems=status.problems.filter(item=>!(item.harness===harness&&item.session_id===nativeSessionId));this.saveQuietly();
|
|
87
|
+
} 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
88
|
// A rate limit is waited out (the server says how long; the same idempotency key makes the retry safe), so a
|
|
30
89
|
// large first sync slows down instead of failing its pass and restarting the connector.
|
|
31
90
|
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 +96,53 @@ export class TeamsConnector {
|
|
|
37
96
|
if(!Array.isArray(configuration?.rules))return;
|
|
38
97
|
const active=new Set(configuration.rules.filter(rule=>rule.state==='active').map(rule=>rule.id));let pruned=false;
|
|
39
98
|
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;}
|
|
99
|
+
// A session that stopped syncing under a rule no longer active is no longer that rule's problem.
|
|
100
|
+
const problems=this.state.sync_status?.problems;
|
|
101
|
+
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
102
|
if(pruned)this.save();
|
|
41
103
|
}
|
|
42
104
|
async start({bindings=()=>[],roots=()=>[]}={}) {
|
|
43
105
|
await this.configuration();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
106
|
+
// Heartbeats are serialized (the server takes each sequence number once) and run on their own
|
|
107
|
+
// timer: a long sync pass (a large first capture) never makes a live machine read as offline.
|
|
108
|
+
let beating = Promise.resolve();
|
|
109
|
+
const heartbeat = () => {
|
|
110
|
+
const next = beating.then(async () => {
|
|
111
|
+
this.state.sequence += 1;
|
|
112
|
+
await this.request('POST', `/machines/${this.machineId}/heartbeat`, {
|
|
113
|
+
incarnation_id: this.state.incarnation_id, sequence: this.state.sequence,
|
|
114
|
+
bindings: [...this.nativeEffects.published(), ...await bindings()],
|
|
115
|
+
roots: this.localRoots.map(({id,machine_id,name,harnesses})=>({id,machine_id,name,harnesses})),
|
|
116
|
+
...(this.hostKeyId ? { host_key_id: this.hostKeyId } : {}),
|
|
117
|
+
});
|
|
118
|
+
this.save();
|
|
51
119
|
});
|
|
52
|
-
|
|
120
|
+
beating = next.catch(() => {});
|
|
121
|
+
return next;
|
|
53
122
|
};
|
|
54
|
-
const
|
|
55
|
-
this.localRoots = await roots();
|
|
56
|
-
await heartbeat();
|
|
123
|
+
const pass = async () => {
|
|
57
124
|
const configuration = await this.configuration();
|
|
58
125
|
await this.syncConfiguredSessions(configuration, this.localRoots);
|
|
59
126
|
await heartbeat();
|
|
60
127
|
await this.pollEffects();
|
|
61
128
|
};
|
|
62
|
-
await
|
|
129
|
+
this.localRoots = await roots();
|
|
130
|
+
await heartbeat();
|
|
131
|
+
// The first sync pass runs behind `start`, so the machine's link opens without waiting for it.
|
|
63
132
|
let running = false;
|
|
64
|
-
|
|
65
|
-
if (running || this.abort.signal.aborted) return;
|
|
133
|
+
const syncPass = () => {
|
|
134
|
+
if (running || this.abort.signal.aborted) return null;
|
|
66
135
|
running = true;
|
|
67
|
-
|
|
136
|
+
const run = (async () => { this.localRoots = await roots(); await pass(); })();
|
|
137
|
+
void run.catch(error => { this.lastError = error; }).finally(() => { running = false; });
|
|
138
|
+
return run;
|
|
139
|
+
};
|
|
140
|
+
/** The first sync pass, for a caller that waits on it; the connector keeps running if it fails. */
|
|
141
|
+
this.firstPass = syncPass();
|
|
142
|
+
this.timer = setInterval(syncPass, 15000);
|
|
143
|
+
this.heartbeatTimer = setInterval(() => {
|
|
144
|
+
if (this.abort.signal.aborted) return;
|
|
145
|
+
void heartbeat().catch(error => { this.lastError = error; });
|
|
68
146
|
}, 15000);
|
|
69
147
|
return this.configurationValue;
|
|
70
148
|
}
|
|
@@ -90,14 +168,14 @@ export class TeamsConnector {
|
|
|
90
168
|
pollEffects() { return this.effects.poll(); }
|
|
91
169
|
async syncConfiguredSessions(configuration,roots){
|
|
92
170
|
if(typeof this.core.discover!=='function')return;
|
|
93
|
-
this.followers??=new Map();
|
|
171
|
+
this.followers??=new Map();this.followRetry??=new Map();this.stoppingFollowers??=new Set();
|
|
94
172
|
this.nativeEffects.beginDiscovery();
|
|
95
173
|
const activeRules=new Map(configuration.rules.filter(item=>item.state==='active').map(rule=>[rule.id,rule]));
|
|
96
|
-
for(const [key,follower] of this.followers){const rule=activeRules.get(follower.ruleId);if(!rule||rule.revision!==follower.ruleRevision){
|
|
174
|
+
for(const [key,follower] of this.followers){const rule=activeRules.get(follower.ruleId);if(!rule||rule.revision!==follower.ruleRevision){this.stopFollower(key);}}
|
|
97
175
|
// A rule covers its root folder and every folder under it. Core discovery filters that subtree before it
|
|
98
176
|
// reads any session's tail, so a beat costs one bounded scan per root, not one over every session on the
|
|
99
177
|
// machine; rules over the same root and harnesses share the scan.
|
|
100
|
-
const discoveries=new Map();
|
|
178
|
+
const discoveries=new Map(),followCandidates=[];
|
|
101
179
|
for(const rule of activeRules.values()){
|
|
102
180
|
const root=roots.find(item=>item.id===rule.selector.root_id);
|
|
103
181
|
if(!root)continue;
|
|
@@ -125,22 +203,65 @@ export class TeamsConnector {
|
|
|
125
203
|
// the pass. The session keeps its native binding either way, so it can still be attached to.
|
|
126
204
|
let captured = true;
|
|
127
205
|
if (this.sessionChanged(options)) try { await this.capture(options); } catch (error) {
|
|
128
|
-
if (error
|
|
129
|
-
captured = false; this.
|
|
206
|
+
if (connectionError(error) || this.abort.signal.aborted) throw error;
|
|
207
|
+
captured = false; this.noteSessionProblem(options, error);
|
|
130
208
|
process.stderr.write(`supercode teams: ${harness} session ${nativeSessionId} not synced: ${error.message}\n`);
|
|
131
209
|
}
|
|
132
210
|
try { await this.nativeEffects.discover(descriptor, rule.id); } catch (error) { this.lastError = error; }
|
|
133
211
|
if (!captured) continue;
|
|
134
|
-
|
|
135
|
-
if(this.followers.has(key)||typeof this.core.session(locator).follow!=='function')continue;
|
|
136
|
-
const controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
|
|
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);});
|
|
140
|
-
this.followers.set(key,{ruleId:rule.id,ruleRevision:rule.revision,controller,task});
|
|
212
|
+
followCandidates.push({key:`${rule.id}:${harness}:${nativeSessionId}`,activity:this.sessionActivity(options),options});
|
|
141
213
|
}
|
|
142
214
|
}
|
|
143
215
|
this.nativeEffects.endDiscovery();
|
|
216
|
+
this.rotateFollowers(followCandidates);
|
|
217
|
+
}
|
|
218
|
+
// When a session was last active: its descriptor's updated_at_ms, else its native file's modification time.
|
|
219
|
+
sessionActivity({locator,descriptor}){
|
|
220
|
+
let activity=Number.isSafeInteger(descriptor?.updated_at_ms)?descriptor.updated_at_ms:null;
|
|
221
|
+
const path=locator?.storage?.kind==='file'&&typeof locator.storage.path==='string'?locator.storage.path:null;
|
|
222
|
+
if(path)try{activity=Math.max(activity??0,statSync(path).mtimeMs);}catch{}
|
|
223
|
+
return activity;
|
|
224
|
+
}
|
|
225
|
+
// Follows the FOLLOW_LIMIT most recently active candidates (active within the window) and stops every other
|
|
226
|
+
// follower. A capacity refusal is not a session's failure: the connector backs off opening follows for a while.
|
|
227
|
+
rotateFollowers(candidates){
|
|
228
|
+
const now=this.clock.nowMs(),seen=new Set(),wanted=[];
|
|
229
|
+
for(const candidate of candidates.filter(item=>item.activity!==null&&now-item.activity<=FOLLOW_ACTIVE_WINDOW_MS).sort((a,b)=>b.activity-a.activity)){
|
|
230
|
+
if(seen.has(candidate.key)||typeof this.core.session(candidate.options.locator).follow!=='function')continue;
|
|
231
|
+
seen.add(candidate.key);wanted.push(candidate);if(wanted.length>=FOLLOW_LIMIT)break;
|
|
232
|
+
}
|
|
233
|
+
for(const key of this.followers.keys())if(!seen.has(key))this.stopFollower(key);
|
|
234
|
+
for(const [key,until] of this.followRetry)if(until<=now)this.followRetry.delete(key);
|
|
235
|
+
for(const {key,options} of wanted){
|
|
236
|
+
if(this.followers.has(key)||this.followRetry.has(key))continue;
|
|
237
|
+
// A stopped follower holds its slot until it has closed, so live follows never exceed the bound.
|
|
238
|
+
if((this.followBackoffUntil??0)>now||this.followers.size+this.stoppingFollowers.size>=FOLLOW_LIMIT)break;
|
|
239
|
+
this.follow(key,options);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
stopFollower(key){
|
|
243
|
+
const follower=this.followers.get(key);if(!follower)return;
|
|
244
|
+
follower.controller.abort();this.followers.delete(key);
|
|
245
|
+
this.stoppingFollowers.add(follower.task);void follower.task.finally(()=>this.stoppingFollowers.delete(follower.task));
|
|
246
|
+
}
|
|
247
|
+
follow(key,options){
|
|
248
|
+
const {locator}=options,controller=new AbortController(),stop=()=>controller.abort(this.abort.signal.reason);this.abort.signal.addEventListener('abort',stop,{once:true});
|
|
249
|
+
// A follow event may land between beats, after the rule changed: it reads the configuration afresh.
|
|
250
|
+
const followed={...options,configuration:null,stamp:undefined};
|
|
251
|
+
const task=(async()=>{
|
|
252
|
+
for await(const _event of this.core.session(locator).follow({signal:controller.signal})){
|
|
253
|
+
if(controller.signal.aborted)break;
|
|
254
|
+
// A capture that fails is that session's problem, as on a beat; the follow goes on.
|
|
255
|
+
try{await this.capture(followed);}catch(error){if(controller.signal.aborted)break;if(connectionError(error))this.lastError=error;else this.noteSessionProblem(followed,error);}
|
|
256
|
+
}
|
|
257
|
+
})().catch(error=>{
|
|
258
|
+
if(controller.signal.aborted)return;
|
|
259
|
+
// The follow itself failed: the beat's capture still covers the session, so it is not listed as not syncing.
|
|
260
|
+
// A capacity refusal pauses every new follow for a while; any other failure retries this one after a while.
|
|
261
|
+
if(capacityError(error))this.followBackoffUntil=this.clock.nowMs()+FOLLOW_RETRY_MS;
|
|
262
|
+
else{this.followRetry.set(key,this.clock.nowMs()+FOLLOW_RETRY_MS);this.lastError=error;}
|
|
263
|
+
}).finally(()=>{this.abort.signal.removeEventListener('abort',stop);if(this.followers.get(key)?.controller===controller)this.followers.delete(key);});
|
|
264
|
+
this.followers.set(key,{ruleId:options.ruleId,ruleRevision:options.ruleRevision,controller,task});
|
|
144
265
|
}
|
|
145
266
|
// What a session's native store looked like when discovery listed it: its file's size and modification
|
|
146
267
|
// time (read before any load, so a write after it reads as a change), those of the file's SQLite write-ahead
|
|
@@ -170,7 +291,7 @@ export class TeamsConnector {
|
|
|
170
291
|
this.save();
|
|
171
292
|
}
|
|
172
293
|
capture(options) {
|
|
173
|
-
const work=this.work.then(()=>this.captureSerial(options));
|
|
294
|
+
const work=this.work.then(()=>this.captureSerial(options)).then(receipt=>{this.clearSessionProblem(options.harness,options.nativeSessionId);return receipt;});
|
|
174
295
|
this.work=work.catch(()=>{});
|
|
175
296
|
return work;
|
|
176
297
|
}
|
|
@@ -186,7 +307,7 @@ export class TeamsConnector {
|
|
|
186
307
|
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
308
|
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
309
|
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;}
|
|
310
|
+
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
311
|
if(!previous&&backfillPolicy.mode==='since'){
|
|
191
312
|
if(!Number.isSafeInteger(backfillPolicy.since))throw Object.assign(new Error('since backfill requires an integer timestamp'),{code:'invalid_request'});
|
|
192
313
|
const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
|
|
@@ -202,7 +323,10 @@ export class TeamsConnector {
|
|
|
202
323
|
// timestamps; a `none` rule cannot tell old history from new, so that session waits for an explicit
|
|
203
324
|
// `supercode teams sync resume RULE --backfill all` (or a since window), which republishes it whole.
|
|
204
325
|
if(!clearBaseline&&effectivePrevious?.baseline?.length&&effectivePrevious.baseline.every(x=>/^\d+$/.test(x))){
|
|
205
|
-
|
|
326
|
+
let baselineMoved=effectivePrevious.baseline.some((native_key,index)=>messages[index]?.native_key!==native_key||hash(canonicalJson(messages[index].value))!==effectivePrevious.baseline_digests?.[index]);
|
|
327
|
+
// A rule that started mid tool call kept back the core's placeholder result; the real result replaces it.
|
|
328
|
+
const settled=baselineMoved&&backfillPolicy.mode==='none'?settledToolTail(effectivePrevious,messages):null;
|
|
329
|
+
if(settled){baselineMoved=false;effectivePrevious={...effectivePrevious,baseline_digests:settled};this.state.origins[key]={...this.state.origins[key],baseline_digests:settled};this.save();}
|
|
206
330
|
if(baselineMoved&&backfillPolicy.mode==='since'&&Number.isSafeInteger(backfillPolicy.since)){
|
|
207
331
|
const excluded=messages.filter(message=>message.recorded_at==null||message.recorded_at<backfillPolicy.since);
|
|
208
332
|
effectivePrevious={...effectivePrevious,baseline:excluded.map(message=>message.native_key),baseline_digests:excluded.map(message=>hash(canonicalJson(message.value)))};
|
|
@@ -257,6 +381,6 @@ export class TeamsConnector {
|
|
|
257
381
|
if(!Array.isArray(configuration?.rules))return false;
|
|
258
382
|
return !configuration.rules.some(rule=>rule.id===item.begin.rule_id&&rule.revision===item.begin.rule_revision&&rule.state==='active');
|
|
259
383
|
}
|
|
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();}
|
|
384
|
+
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;}
|
|
385
|
+
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
386
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.24",
|
|
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",
|