@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.
- 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 +94 -23
- 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,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);}
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
110
|
+
beating = next.catch(() => {});
|
|
111
|
+
return next;
|
|
53
112
|
};
|
|
54
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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",
|