@volter/supercode-teams 0.2.171 → 0.2.173

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/teams.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  // title set <pane> <text> | title ls
20
20
  // attention ls | rank | request <pane> [--urgency now|soon|whenever] [--reason text] | clear <pane>
21
21
  // watch [--interval ms] [--for ms]
22
- // launches get <key> | list operators: keyed node launch receipts
22
+ // launches get <key> | cancel <key> | list operators: keyed receipts; cancel only unissued keys
23
23
  // rpc <harness.v1 method> [json-params] operators: one harness.v1 request
24
24
  // log verify
25
25
  //
@@ -323,8 +323,9 @@ async function main() {
323
323
  return withNode(async (rpc) => out(await rpc.call('jobs.list', {})));
324
324
  case 'launches': {
325
325
  if (sub === 'get' && rest[0]) return withNode(async (rpc) => out(await rpc.call('launches.get', { key: rest[0] })));
326
+ if (sub === 'cancel' && rest[0]) return withNode(async (rpc) => out(await rpc.call('launches.cancel', { key: rest[0] })));
326
327
  if (sub === 'list') return withNode(async (rpc) => out(await rpc.call('launches.list', {})));
327
- die('launches get <key> | launches list [--machine NAME]');
328
+ die('launches get <key> | cancel <key> | list [--machine NAME]');
328
329
  return;
329
330
  }
330
331
  case 'rpc': {
package/machine.mjs CHANGED
@@ -7,7 +7,7 @@
7
7
  // request ids rewritten per connection, notifications routed
8
8
  // by the subscription/connection they name (never fanned out)
9
9
  // stream "terminal" a pane attachment under observe|control
10
- // stream "node" describe, launch, launches.get|list, panes.*, files.*,
10
+ // stream "node" describe, launch, launches.get|cancel|list, panes.*, files.*,
11
11
  // ports.*, attention.*, title.*
12
12
  // stream "frontend" frontend.v2 to a hosted runtime, one client id per caller
13
13
  // stream "port" one TCP connection to an announced port
@@ -1345,6 +1345,19 @@ export class TeamsMachine extends MachineCore {
1345
1345
  });
1346
1346
  }
1347
1347
 
1348
+ async #launchReceipt(found) {
1349
+ const session = found.pane ? await this.#paneByContext(found.pane) : null;
1350
+ return {
1351
+ key: found.key, pane: found.pane ?? null, status: found.status, by: found.by ?? null,
1352
+ cancelled: found.status === 'cancelled', outcome: found.outcome ?? null,
1353
+ cancelled_at: found.cancelled_at ?? null,
1354
+ live: Boolean(session && !session.dead), label: session?.label ?? null, activeCommand: session?.activeCommand ?? null,
1355
+ exit_output: found.pane && (!session || session.dead) ? paneExitOutput(found.pane) : null,
1356
+ exit: session?.dead ? deadPaneExit(session) : found.pane && !session ? paneExit(found.pane) : null,
1357
+ ...initialInputFacts(found),
1358
+ };
1359
+ }
1360
+
1348
1361
  async #dispatch(entry, method, params) {
1349
1362
  // A server-forwarded caller is re-admitted on every door: a grant revoked or expired
1350
1363
  // since the stream opened stops mattering NOW, not at the next stream.
@@ -1439,16 +1452,24 @@ export class TeamsMachine extends MachineCore {
1439
1452
  const programName = String(params.program).split(/[\\/]/).pop().replace(/\.(?:exe|cmd)$/i, '');
1440
1453
  if (programName === 'codex') params = { ...params, kind: 'codex' };
1441
1454
  const key = params.key ?? `lk_${randomBytes(9).toString('base64url')}`;
1442
- const existing = this.launches.find(key);
1443
- // A key whose launch is in flight, or whose pane still lives, is reused; a failed
1444
- // launch, or one whose pane has ended, may be launched again.
1445
- if (existing && (existing.status === 'issued' || (existing.status === 'ready' && await this.#paneByContext(existing.pane)))) {
1446
- return { key, pane: existing.pane, reused: true, status: existing.status };
1455
+ const by = await this.#callerOf(entry, params).catch(() => null);
1456
+ // No await between the final journal check and issuance: cancellation and issuance
1457
+ // serialize in this daemon even when caller resolution or pane reads yielded.
1458
+ let existing = this.launches.find(key);
1459
+ while (existing) {
1460
+ if (existing.status === 'cancelled') throw Object.assign(new Error(`launch key ${key} was cancelled before issuance; use a new key`), { code: 409, reason: 'launch_key_cancelled' });
1461
+ if (existing.status === 'issued') return { key, pane: existing.pane, reused: true, status: existing.status };
1462
+ if (existing.status === 'ready') {
1463
+ const session = await this.#paneByContext(existing.pane);
1464
+ const latest = this.launches.find(key);
1465
+ if (JSON.stringify(latest) !== JSON.stringify(existing)) { existing = latest; continue; }
1466
+ if (session && !session.dead) return { key, pane: existing.pane, reused: true, status: existing.status };
1467
+ }
1468
+ break;
1447
1469
  }
1448
1470
  const contextKey = params.pane ?? `p_${randomBytes(9).toString('base64url')}`;
1449
1471
  // Who started the pane: the launching session's (or board's) address, null for a person. A session's later
1450
1472
  // input on the pane is refused unless it is this one (#refuseForeign); close is not creator-restricted.
1451
- const by = await this.#callerOf(entry, params).catch(() => null);
1452
1473
  // Who to tell when the pane ends (`open --new … --notify-when-ended`): a session's mail address, and this
1453
1474
  // machine's name as the address a notice from its pane comes from.
1454
1475
  if (params.notify !== undefined && !(params.notify && typeof params.notify.to === 'string' && /^sc:[^:\s]+:[^:\s]+:\S+$/.test(params.notify.to) && typeof params.notify.machine === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(params.notify.machine))) throw Object.assign(new Error('launch notify is { to: <sc: address>, machine: <this machine\'s mail name> }'), { code: 400 });
@@ -1523,13 +1544,21 @@ export class TeamsMachine extends MachineCore {
1523
1544
  if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1524
1545
  const found = this.launches.find(params.key);
1525
1546
  if (!found) return null;
1526
- const session = await this.#paneByContext(found.pane);
1527
- return {
1528
- exit_output: !session || session.dead ? paneExitOutput(found.pane) : null,
1529
- exit: session?.dead ? deadPaneExit(session) : !session ? paneExit(found.pane) : null,
1530
- key: found.key, pane: found.pane, live: Boolean(session && !session.dead), label: session?.label ?? null, activeCommand: session?.activeCommand ?? null,
1531
- ...initialInputFacts(found),
1532
- };
1547
+ return this.#launchReceipt(found);
1548
+ }
1549
+ case 'launches.cancel': {
1550
+ if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
1551
+ if (typeof params.key !== 'string' || !params.key.trim()) throw Object.assign(new Error('launch cancellation requires a nonempty key'), { code: 400 });
1552
+ const by = await this.#callerOf(entry, params).catch(() => null);
1553
+ // Retire only an absent key. An issued, live or failed launch keeps its actual
1554
+ // receipt; cancellation never closes a pane or guesses that issuance did not happen.
1555
+ let found = this.launches.find(params.key);
1556
+ if (!found) {
1557
+ this.launches.record({ key: params.key, pane: null, by, status: 'cancelled', outcome: 'not_issued', cancelled_at: Date.now() });
1558
+ found = this.launches.find(params.key);
1559
+ act('launches.cancel', params.key, 'ok', 'not issued; key retired');
1560
+ } else act('launches.cancel', params.key, found.status === 'cancelled' ? 'ok' : 'refused', found.status);
1561
+ return this.#launchReceipt(found);
1533
1562
  }
1534
1563
  case 'launches.list': {
1535
1564
  // Everything this daemon launched, by key, with whether the pane still lives: how an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@volter/supercode-teams",
3
- "version": "0.2.171",
3
+ "version": "0.2.173",
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",
@@ -5,3 +5,17 @@ export { cursorToken, cursorHash };
5
5
  export const normalizeSearch = text => String(text).normalize('NFC').toLowerCase();
6
6
  export function descriptor(row, auth, grants=[]) { const projection=JSON.parse(row.descriptor_json); projection.actions ??= {}; const read=canRead(auth,row,grants); for(const action of ['read','follow','export_text','export_native','manage','attach_observe','interact','approve','interrupt','terminate','continue']) projection.actions[action] ??={allowed:action==='read'||action==='follow'||action==='export_text'?read:false,reason:read?(action==='read'||action==='follow'||action==='export_text'?null:'unsupported'):'forbidden'}; return projection; }
7
7
  export function textFromContent(content) { if(typeof content==='string')return content; if(!Array.isArray(content))return ''; return content.filter(x=>x&&typeof x==='object'&&x.type==='text'&&typeof x.text==='string').map(x=>x.text).join('\n'); }
8
+
9
+ /**
10
+ * The native bindings sessions have now (contracts: Descriptor.bindings), by session id: each one a machine reported in
11
+ * its latest heartbeat, from the incarnation it runs as, on a machine still enrolled and seen within the window attach
12
+ * uses (effects.mjs), so a read offers only bindings attach would accept. Only the contract's fields are served. The
13
+ * captured descriptor never holds them; a heartbeat replaces them, so every read takes them fresh.
14
+ */
15
+ const BINDING_FIELDS=['id','machine_id','incarnation_id','native_session_id','native_runtime_id','harness','state','observed_at','native_capabilities'];
16
+ export async function liveBindings(api,teamId,sessionId=null){
17
+ const rows=await api.store.query(`SELECT b.session_id,b.descriptor_json FROM bindings b JOIN machines m ON m.team_id=b.team_id AND m.id=b.machine_id AND m.incarnation_id=b.incarnation_id AND m.revoked_at IS NULL AND m.last_seen_at>? WHERE b.team_id=?${sessionId?' AND b.session_id=?':''} ORDER BY b.id`,[api.clock.nowMs()-45000,teamId,...(sessionId?[sessionId]:[])]);
18
+ const bySession=new Map();
19
+ for(const row of rows){const reported=JSON.parse(row.descriptor_json),binding=Object.fromEntries(BINDING_FIELDS.filter(field=>field in reported).map(field=>[field,reported[field]]));const list=bySession.get(row.session_id)??[];list.push(binding);bySession.set(row.session_id,list);}
20
+ return bySession;
21
+ }
@@ -2,7 +2,7 @@ import { appGrantAllows } from '@volter/teams/server/auth';
2
2
  import { appAuthentication } from '@volter/teams/server/apps';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { validateListQuery } from '../contracts/index.mjs';
5
- import { cursorHash, cursorToken } from './catalog.mjs';
5
+ import { cursorHash, cursorToken, liveBindings } from './catalog.mjs';
6
6
  import { writeSse } from '@volter/teams/server/events';
7
7
 
8
8
  function openStream(api, req, res, auth, requestId, fail) {
@@ -188,10 +188,13 @@ async function catalogWindow(api, auth, query) {
188
188
  }
189
189
  return readable(row);
190
190
  };
191
- const items = [];
191
+ // The window carries the bindings machines report now, so a stream (re-read on every change and every 15 s) tells a
192
+ // watcher when a session's doors open or close.
193
+ const items = [], bindings = await liveBindings(api, auth.team_id);
192
194
  for (const row of rows) {
193
195
  if (row.canonical_id || !readable(row)) continue;
194
196
  const descriptor = JSON.parse(row.descriptor_json);
197
+ descriptor.bindings = bindings.get(row.id) ?? [];
195
198
  for (const field of ['parent_id', 'continued_from']) if (descriptor[field] && !readableReference(descriptor[field])) descriptor[field] = null;
196
199
  if (query.user && (query.user === 'me' ? auth.principal_id : query.user) !== descriptor.user?.principal_id) continue;
197
200
  if (query.repo && query.repo !== descriptor.repository?.key) continue;
@@ -220,7 +223,8 @@ export async function handleCatalogEvents({ api, req, res, auth, url, requestId,
220
223
  const enqueue = streamQueue(stream, async () => {
221
224
  const page = await catalogWindow(api, auth, query);
222
225
  // Authorization and expiry can change a window without creating a session revision.
223
- const digest = createHash('sha256').update(JSON.stringify(page.data)).digest('hex');
226
+ // A binding's observed_at moves with every heartbeat; only a change of what the window says is a reset.
227
+ const digest = createHash('sha256').update(JSON.stringify(page.data, (key, value) => key === 'bindings' && Array.isArray(value) ? value.map(({ observed_at: _seen, ...binding }) => binding) : value)).digest('hex');
224
228
  const cursor = `${page.revision}:${page.authEpoch}:${digest}`;
225
229
  if (cursor !== lastCursor && stream.write({ type: 'catalog_reset', cursor, revision: page.revision, data: page.data })) lastCursor = cursor;
226
230
  });
@@ -11,7 +11,7 @@ import { createHash } from 'node:crypto';
11
11
  import { bearer, hashToken, canRead, canManage, isAdmin } from '@volter/teams/server/auth';
12
12
  import { storageReport } from './storage-report.mjs';
13
13
  import { validateListQuery, validateSearchQuery, validateReadQuery, validateCaptureBegin, validateCapturePart, canonicalJson } from '../contracts/index.mjs';
14
- import { normalizeSearch, cursorHash, cursorToken } from './catalog.mjs';
14
+ import { normalizeSearch, cursorHash, cursorToken, liveBindings } from './catalog.mjs';
15
15
  import { handleOtlpRoute, sha256, validateArtifactName } from './ingest.mjs';
16
16
  import { handleCatalogEvents, handleSessionEvents, sessionNames } from './event-routes.mjs';
17
17
  import { exact } from '@volter/teams/server/request-json';
@@ -136,19 +136,6 @@ const managerGuard=(auth,sessionId,now,{governance=false}={})=>({
136
136
  async function resolve(api,auth,ref){const head='SELECT s.*,v.descriptor_json,v.sort_at FROM sessions s JOIN session_versions v ON v.team_id=s.team_id AND v.session_id=s.id AND v.revision=s.revision WHERE s.team_id=? AND ',native=/^([a-z][a-z0-9-]*):(.+)$/.exec(ref);let rows;if(native)rows=await api.store.query(head+'s.id IN (SELECT session_id FROM origins WHERE team_id=? AND harness=? AND native_id=? AND deleted_at IS NULL)',[auth.team_id,auth.team_id,native[1],native[2]]);else if(ref.length>=36)rows=await api.store.query(head+'s.id=?',[auth.team_id,ref]);else if(ref.length>=8)rows=await api.store.query(head+'s.id LIKE ?',[auth.team_id,`${ref}%`]);else rows=[];const visible=[];for(let r of rows){r=await canonicalSession(api,auth.team_id,r);r=await api.store.one('SELECT s.*,v.descriptor_json,v.sort_at FROM sessions s JOIN session_versions v ON v.team_id=s.team_id AND v.session_id=s.id AND v.revision=s.revision WHERE s.team_id=? AND s.id=?',[auth.team_id,r.id]);if(r&&await canReadMerged(api,auth,r)&&!visible.some(x=>x.id===r.id))visible.push(r);}if(visible.length>1)fail(400,'ambiguous_reference','ambiguous session reference');if(!visible.length)fail(404,'not_found','not found');return visible[0];}
137
137
  function qobj(url){return Object.fromEntries([...url.searchParams].map(([k,v])=>[k,['limit','since','until','tail','revision'].includes(k)?Number(v):k==='include_children'?v==='true':v]));}
138
138
  function projection(row){return JSON.parse(row.descriptor_json);}
139
- /**
140
- * The native bindings sessions have now (contracts: Descriptor.bindings), by session id: each one a machine reported in
141
- * its latest heartbeat, from the incarnation it runs as, on a machine still enrolled and seen within the window attach
142
- * uses (effects.mjs), so a read offers only bindings attach would accept. Only the contract's fields are served. The
143
- * captured descriptor never holds them; a heartbeat replaces them, so every read takes them fresh.
144
- */
145
- const BINDING_FIELDS=['id','machine_id','incarnation_id','native_session_id','native_runtime_id','harness','state','observed_at','native_capabilities'];
146
- async function liveBindings(api,teamId,sessionId=null){
147
- const rows=await api.store.query(`SELECT b.session_id,b.descriptor_json FROM bindings b JOIN machines m ON m.team_id=b.team_id AND m.id=b.machine_id AND m.incarnation_id=b.incarnation_id AND m.revoked_at IS NULL AND m.last_seen_at>? WHERE b.team_id=?${sessionId?' AND b.session_id=?':''} ORDER BY b.id`,[api.clock.nowMs()-45000,teamId,...(sessionId?[sessionId]:[])]);
148
- const bySession=new Map();
149
- for(const row of rows){const reported=JSON.parse(row.descriptor_json),binding=Object.fromEntries(BINDING_FIELDS.filter(field=>field in reported).map(field=>[field,reported[field]]));const list=bySession.get(row.session_id)??[];list.push(binding);bySession.set(row.session_id,list);}
150
- return bySession;
151
- }
152
139
  async function readerProjection(api,auth,row,bindings=null){
153
140
  const view=projection(row);
154
141
  view.bindings=(bindings??await liveBindings(api,auth.team_id,row.id)).get(row.id)??[];