@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 +3 -2
- package/machine.mjs +43 -14
- package/package.json +1 -1
- package/server/catalog.mjs +14 -0
- package/server/event-routes.mjs +7 -3
- package/server/harness-routes.mjs +1 -14
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
|
|
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> |
|
|
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
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
|
|
1446
|
-
|
|
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
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
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.
|
|
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",
|
package/server/catalog.mjs
CHANGED
|
@@ -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
|
+
}
|
package/server/event-routes.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)??[];
|