@volter/supercode-teams 0.2.179 → 0.3.0
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/README.md +2 -1
- package/bin/fleet.mjs +4 -1
- package/bin/teams.mjs +5 -5
- package/bin/workspace/agent-session.mjs +20 -0
- package/bin/workspace/args.mjs +3 -3
- package/bin/workspace.mjs +170 -4
- package/board-process-guard.mjs +52 -0
- package/client.mjs +3 -29
- package/connector/agent-closures.mjs +28 -0
- package/connector/index.d.ts +1 -0
- package/connector/index.mjs +76 -5
- package/context.mjs +1 -34
- package/event-client.mjs +1 -0
- package/machine-door.mjs +1 -36
- package/machine.mjs +205 -10
- package/native-codex-pane.mjs +3 -2
- package/package.json +3 -2
- package/server/harness-routes.mjs +8 -3
- package/server/index.mjs +1 -2
- package/server/store-core.mjs +15 -2
- package/session-notice-target.mjs +39 -0
- package/worker/index.mjs +1 -5
- package/workspace-client.d.ts +4 -0
- package/workspace-client.mjs +2 -185
- package/lines.mjs +0 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@volter/supercode-teams",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
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",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"./machine": "./machine.mjs",
|
|
11
11
|
"./browser-attach": "./browser-attach.mjs",
|
|
12
12
|
"./attention": "./attention.mjs",
|
|
13
|
+
"./event-client": "./event-client.mjs",
|
|
13
14
|
"./bin": "./bin/teams.mjs",
|
|
14
15
|
"./fleet": "./fleet.mjs",
|
|
15
16
|
"./bin/fleet": "./bin/fleet.mjs",
|
|
@@ -62,7 +63,7 @@
|
|
|
62
63
|
"@volter/supercode-harness-sdk": "^0.3.43",
|
|
63
64
|
"@volter/supercode-terminal": "^0.2.54",
|
|
64
65
|
"@volter/identity": "^0.4.1",
|
|
65
|
-
"@volter/teams": "
|
|
66
|
+
"@volter/teams": "0.2.0",
|
|
66
67
|
"@xterm/addon-serialize": "^0.14.0",
|
|
67
68
|
"@xterm/headless": "^6.0.0",
|
|
68
69
|
"protobufjs": "8.8.0",
|
|
@@ -125,7 +125,7 @@ export const harnessRoutes = Object.freeze({ preAuth, authed, beforeIdempotency,
|
|
|
125
125
|
|
|
126
126
|
async function canonicalSession(api,team,row){const seen=new Set();while(row?.canonical_id){if(seen.has(row.id))fail(409,'conflict','correlation cycle');seen.add(row.id);row=await api.store.one('SELECT * FROM sessions WHERE team_id=? AND id=?',[team,row.canonical_id]);}return row;}
|
|
127
127
|
async function constituents(api,team,row){const root=await canonicalSession(api,team,row);return api.store.query('WITH RECURSIVE family(id) AS (SELECT ? UNION SELECT s.id FROM sessions s JOIN family f ON s.canonical_id=f.id WHERE s.team_id=?) SELECT s.* FROM sessions s JOIN family f ON f.id=s.id WHERE s.team_id=?',[root.id,team,team]);}
|
|
128
|
-
async function canReadMerged(api,auth,row){for(const member of await constituents(api,auth.team_id,row))if(!canRead(auth,member,await grants(api,auth.team_id,member.id)))return false;return true;}
|
|
128
|
+
async function canReadMerged(api,auth,row){if(auth.principal_kind==='agent'){try{await api.store.authorizedSession({teamId:auth.team_id,principalId:auth.principal_id,credentialId:auth.credential_id,ref:row.id,now:api.clock.nowMs()});return true;}catch(error){if([401,403,404,410].includes(error.status))return false;throw error;}}for(const member of await constituents(api,auth.team_id,row))if(!canRead(auth,member,await grants(api,auth.team_id,member.id)))return false;return true;}
|
|
129
129
|
const managerGuard=(auth,sessionId,now,{governance=false}={})=>({
|
|
130
130
|
sql:`SELECT 1 ok FROM sessions s JOIN memberships m ON m.team_id=s.team_id AND m.principal_id=? WHERE s.team_id=? AND s.id=? AND s.deleted_at IS NULL AND m.active=1 AND m.role!='viewer' AND (?=1 AND m.role IN ('owner','admin') OR s.custodian_id=? OR EXISTS (SELECT 1 FROM session_grants g WHERE g.team_id=s.team_id AND g.session_id=s.id AND g.role='manager' AND (g.expires_at IS NULL OR g.expires_at>?) AND (g.subject_kind='principal' AND g.subject_id=? OR g.subject_kind='group' AND EXISTS (SELECT 1 FROM group_members gm WHERE gm.team_id=s.team_id AND gm.group_id=g.subject_id AND gm.principal_id=?))))`,
|
|
131
131
|
params:[auth.principal_id,auth.team_id,sessionId,governance?1:0,auth.principal_id,now,auth.principal_id,auth.principal_id],
|
|
@@ -171,6 +171,7 @@ async function listSessions(api,res,auth,url,requestId,search){
|
|
|
171
171
|
const rows=[...currentRows,...olderRows],currentById=new Map(currentRows.map(row=>[row.id,row]));
|
|
172
172
|
const boundaryNow=api.clock.nowMs();
|
|
173
173
|
if(!membership?.active||credential?.revoked_at!=null||(credential?.expires_at!=null&&credential.expires_at<=boundaryNow))fail(401,'authentication_required','authentication required');
|
|
174
|
+
if(cursorKey&&!cursor)fail(409,'resync_required','cursor expired, mismatched, or authorization changed');
|
|
174
175
|
let tuples;
|
|
175
176
|
if(cursor){
|
|
176
177
|
if(cursor.kind!==(search?'search':'list')||cursor.expires_at<=boundaryNow||cursor.auth_epoch!==team.auth_epoch||canonicalJson(JSON.parse(cursor.query_json))!==canonicalJson(normalizedQuery))fail(409,'resync_required','cursor expired, mismatched, or authorization changed');
|
|
@@ -441,13 +442,17 @@ async function viewBindingRoute(api,req,res,auth,ref,requestId){
|
|
|
441
442
|
const bindings=await list();
|
|
442
443
|
// Native transport destinations are custody information, not an extra transcript-reader capability.
|
|
443
444
|
if(session.custodian_id===auth.principal_id&&!auth.installation_id)for(const binding of bindings){
|
|
444
|
-
const resolution=await sessionDeliveryResolution(api,auth.team_id,{sessionRef:session.id,...binding},true);
|
|
445
|
+
const resolution=await sessionDeliveryResolution(api,auth.team_id,{sessionRef:session.id,...binding},true,auth);
|
|
445
446
|
binding.delivery=resolution.target&&resolution.target.harness!==session.harness
|
|
446
447
|
? {...resolution,state:'unavailable',reason:'harness_mismatch'} : resolution;
|
|
447
448
|
}
|
|
448
449
|
send(res,200,result(api,auth,{session_id:session.id,harness:session.harness,bindings}),requestId);return;
|
|
449
450
|
}
|
|
450
|
-
|
|
451
|
+
// The owner binds, in person or through an app they installed and consented to `sessions.access` (the app acts for its
|
|
452
|
+
// installer); an app installed by anyone else binds nothing of theirs.
|
|
453
|
+
const installer=auth.installation_id&&auth.app_grants==null&&auth.app_actions?.includes('sessions.access')
|
|
454
|
+
?(await api.store.one("SELECT installer_id FROM app_installations WHERE id=? AND team_id=? AND state='active'",[auth.installation_id,auth.team_id]))?.installer_id:null;
|
|
455
|
+
if(session.custodian_id!==auth.principal_id&&session.custodian_id!==installer)fail(403,'forbidden',"only the session's owner binds it to a view");
|
|
451
456
|
const body=await parseJson(req);exact(body,['issuer','room','conversation']);
|
|
452
457
|
if(typeof body.room!=='string'||!body.room||typeof body.conversation!=='string'||!body.conversation)fail(400,'invalid_request','room and conversation are required');
|
|
453
458
|
if(!await installedIssuer(api,body.issuer,'delegation.sessions',auth.team_id))fail(400,'invalid_request','this issuer is not an installed session integration');
|
package/server/index.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// The Node host: one process on a data directory (SQLite in a worker thread, the
|
|
2
2
|
// bootstrap credential in a protected file beside it), serving HTTP and the
|
|
3
3
|
// machine doors on one listener. The server itself is core.mjs's.
|
|
4
|
-
import { identityOrganizationsFromEnv } from '@volter/teams/server/identity-orgs';
|
|
5
4
|
import { createServer as createHttpServer } from 'node:http';
|
|
6
5
|
import { mkdirSync, openSync, closeSync, writeFileSync, readFileSync, renameSync, chmodSync, unlinkSync, existsSync } from 'node:fs';
|
|
7
6
|
import { join } from 'node:path';
|
|
@@ -21,7 +20,7 @@ export function createTeamsServer({dataDir,listen={host:'127.0.0.1',port:7331},p
|
|
|
21
20
|
const loopback=['127.0.0.1','::1','localhost'].includes(listen.host??'127.0.0.1');
|
|
22
21
|
publicOrigin=normalizePublicOrigin(publicOrigin);
|
|
23
22
|
if(!loopback&&!publicOrigin?.startsWith('https:'))throw new TypeError('non-loopback Teams servers require an HTTPS publicOrigin');
|
|
24
|
-
const api=createTeamsApi({clock,ids,publicOrigin,
|
|
23
|
+
const api=createTeamsApi({clock,ids,publicOrigin,uploadLimitBytes});
|
|
25
24
|
// The harness's own routes on this server (harness-routes.mjs); the handler owns the neutral ones.
|
|
26
25
|
api.attach({harnessRoutes});
|
|
27
26
|
const events=api.events;
|
package/server/store-core.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { ingestOtlpBatch, correlateSessions } from './arc2-transactions.mjs';
|
|
|
7
7
|
import { claimEffect, getEffect, listEffects, reportEffect } from './effects-transactions.mjs';
|
|
8
8
|
import { applyCaptureMessages } from './capture-transactions.mjs';
|
|
9
9
|
import { MIGRATIONS, MESSAGE_AT } from '@volter/teams/server/migrations';
|
|
10
|
+
import { AGENT_ACTIVE, AGENT_DESCENDANTS } from '@volter/teams/server/agent-authority';
|
|
10
11
|
import { APP_LINEAGE_REVOKED, appGrantAllows } from '@volter/teams/server/auth';
|
|
11
12
|
|
|
12
13
|
/** Bring a database to the newest schema. `version()`/`setVersion(n)` name where the host records it. */
|
|
@@ -27,7 +28,7 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
27
28
|
const {teamId,principalId,credentialId,ref,now,bound}=args;
|
|
28
29
|
// `bound`: a workplace's session-view token ({issuer, room, conversation, expiresAt}), which reads only a
|
|
29
30
|
// session its owner bound to that Room and Conversation; it holds no credential and is no member.
|
|
30
|
-
let membership=null,appFence=null,grantSource=null;
|
|
31
|
+
let membership=null,appFence=null,grantSource=null,agentGrantSource=null,agentReader=false;
|
|
31
32
|
if((args.delegationDepth??0)>16)domainError(401,'authentication_required','delegation lineage is too deep');
|
|
32
33
|
if(bound){
|
|
33
34
|
if(!get(`SELECT 1 FROM app_installations i JOIN apps a ON a.id=i.app_id JOIN memberships m ON m.team_id=i.team_id AND m.principal_id=i.principal_id JOIN memberships installer ON installer.team_id=i.team_id AND installer.principal_id=i.installer_id WHERE i.team_id=? AND a.issuer=? AND i.id=? AND i.created_at=? AND i.state='active' AND a.state='active' AND m.active=1 AND installer.active=1 AND installer.role IN ('owner','admin') AND EXISTS (SELECT 1 FROM json_each(i.actions_json) WHERE value='delegation.sessions')`,[teamId,bound.issuer,bound.installationId,bound.installedAt]))domainError(401,'authentication_required','integration was uninstalled');
|
|
@@ -45,6 +46,12 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
45
46
|
if(appFence!==null)grantSource=get('SELECT id,principal_id FROM credentials WHERE id=?',[app.subject_credential_id??app.parent_id]);
|
|
46
47
|
}
|
|
47
48
|
if(!credential||!membership)domainError(401,'authentication_required','authentication required');
|
|
49
|
+
const principal=get('SELECT kind FROM principals WHERE id=?',[principalId]);
|
|
50
|
+
if(principal?.kind==='agent'){
|
|
51
|
+
agentGrantSource=get('SELECT c.id,c.principal_id FROM agent_credentials d JOIN credentials c ON c.id=d.parent_credential_id WHERE d.credential_id=?',[credentialId]);
|
|
52
|
+
if(!get(AGENT_ACTIVE,[teamId,principalId])||get(APP_LINEAGE_REVOKED,[credentialId,now]))domainError(401,'authentication_required','agent delegation revoked');
|
|
53
|
+
agentReader=true;
|
|
54
|
+
}
|
|
48
55
|
}
|
|
49
56
|
if(typeof ref!=='string'||ref.length<8)domainError(404,'not_found','not found');
|
|
50
57
|
const groups=all('SELECT group_id FROM group_members WHERE team_id=? AND principal_id=?',[teamId,principalId]).map(row=>row.group_id);
|
|
@@ -69,7 +76,13 @@ export function createStoreHandlers({ all, get, run, tx }) {
|
|
|
69
76
|
if(!grantSource)return false;
|
|
70
77
|
try{authorizedSession({teamId,principalId:grantSource.principal_id,credentialId:grantSource.id,ref:session.id,now,delegationDepth:(args.delegationDepth??0)+1,projectionDepth:1});}catch{return false;}
|
|
71
78
|
}
|
|
72
|
-
if(
|
|
79
|
+
if(agentGrantSource){
|
|
80
|
+
try{authorizedSession({teamId,principalId:agentGrantSource.principal_id,credentialId:agentGrantSource.id,ref:session.id,now,delegationDepth:(args.delegationDepth??0)+1,projectionDepth:1});}catch{return false;}
|
|
81
|
+
}
|
|
82
|
+
if(agentReader&&get(`WITH RECURSIVE owners(id) AS (${AGENT_DESCENDANTS}), family(id) AS (SELECT s.session_id FROM agent_sessions s JOIN owners o ON s.agent_id=o.id WHERE s.team_id=? UNION SELECT s.id FROM sessions s JOIN family f ON s.canonical_id=f.id WHERE s.team_id=?) SELECT 1 FROM family WHERE id=?`,[teamId,principalId,teamId,teamId,session.id]))return true;
|
|
83
|
+
// An agent's standing delegation covers its own family and descendants. Team visibility
|
|
84
|
+
// alone does not open a parent's or sibling's transcript; those require an explicit grant.
|
|
85
|
+
if(!agentReader&&(session.visibility==='team'||session.custodian_id===principalId))return true;
|
|
73
86
|
const grants=all('SELECT subject_kind,subject_id,expires_at FROM session_grants WHERE team_id=? AND session_id=?',[teamId,session.id]);
|
|
74
87
|
return grants.some(grant=>(grant.subject_kind==='principal'&&grant.subject_id===principalId||grant.subject_kind==='group'&&groups.includes(grant.subject_id))&&(grant.expires_at==null||grant.expires_at>now));
|
|
75
88
|
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// A Room reply may resume its existing thread. Configuration is read through the harness's own door.
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function codexNoticeResumeArguments(config){
|
|
5
|
+
if(config?.harness!=='codex'||typeof config.cwd!=='string'||!isAbsolute(config.cwd))return null;
|
|
6
|
+
if(!['untrusted','on-failure','on-request','never'].includes(config.approval_policy))return null;
|
|
7
|
+
const policy=config.sandbox_policy;
|
|
8
|
+
if(!policy||typeof policy!=='object'||Array.isArray(policy)||!['read-only','workspace-write','danger-full-access'].includes(policy.type))return null;
|
|
9
|
+
const keys=['type',...(policy.type==='workspace-write'?['writable_roots','network_access','exclude_tmpdir_env_var','exclude_slash_tmp']:[])];
|
|
10
|
+
if(Object.keys(policy).some(key=>!keys.includes(key)))return null;
|
|
11
|
+
if(policy.writable_roots!==undefined&&(!Array.isArray(policy.writable_roots)||!policy.writable_roots.every(root=>typeof root==='string'&&isAbsolute(root))))return null;
|
|
12
|
+
for(const key of ['network_access','exclude_tmpdir_env_var','exclude_slash_tmp'])if(policy[key]!==undefined&&typeof policy[key]!=='boolean')return null;
|
|
13
|
+
const args=['--ask-for-approval',config.approval_policy,'--sandbox',policy.type];
|
|
14
|
+
if(policy.type==='workspace-write'){
|
|
15
|
+
args.push('-c',`sandbox_workspace_write.writable_roots=${JSON.stringify(policy.writable_roots??[])}`);
|
|
16
|
+
for(const key of ['network_access','exclude_tmpdir_env_var','exclude_slash_tmp'])args.push('-c',`sandbox_workspace_write.${key}=${policy[key]??(key!=='network_access')}`);
|
|
17
|
+
}
|
|
18
|
+
if(typeof config.model==='string'&&config.model)args.push('--model',config.model);
|
|
19
|
+
return args;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function sessionNoticeTarget(address,{machine,mail,discover,recordedConfig}){
|
|
23
|
+
const match=/^sc:([^:]+):([^:]+):([^:\s]+)$/.exec(address??'');
|
|
24
|
+
if(!match||match[1]!==machine)return {state:'unavailable',reason:'native_target_not_local',resumable:false};
|
|
25
|
+
const [, ,harness,id]=match;
|
|
26
|
+
const live=await mail({op:'list'});
|
|
27
|
+
if(live.code!==0||!Array.isArray(live.sessions))throw new Error('native liveness door returned no session catalogue');
|
|
28
|
+
if(live.sessions.some(row=>row.address===address))return {state:'running',reason:null,resumable:false};
|
|
29
|
+
const page=await discover({query:id,harnesses:[harness],limit:200});
|
|
30
|
+
const row=page.sessions?.find(value=>value.locator?.session_id===id&&value.locator?.harness===harness);
|
|
31
|
+
if(!row)return {state:'unavailable',reason:'native_session_not_found',resumable:false};
|
|
32
|
+
if(harness!=='codex')return {state:'stopped',reason:'native_resume_not_supported',resumable:false};
|
|
33
|
+
let config;
|
|
34
|
+
try{config=await recordedConfig(row.locator);}catch{return {state:'stopped',reason:'native_resume_profile_unavailable',resumable:false};}
|
|
35
|
+
const args=codexNoticeResumeArguments(config);
|
|
36
|
+
if(config.session_id!==id)return {state:'stopped',reason:'native_resume_identity_unrecorded',resumable:false};
|
|
37
|
+
return {state:'stopped',reason:args?null:'native_resume_profile_unrecorded',resumable:!!args,
|
|
38
|
+
...(args?{resume:{cwd:config.cwd,args}}:{})};
|
|
39
|
+
}
|
package/worker/index.mjs
CHANGED
|
@@ -6,9 +6,6 @@
|
|
|
6
6
|
// PUBLIC_ORIGIN the https origin the Worker is served at
|
|
7
7
|
// INIT_TEAM the first team's name (used once, at bootstrap)
|
|
8
8
|
// Identity and delegation issuers are registered apps installed by team admins through the public doors.
|
|
9
|
-
// VOLTER_ORG_CLIENT_ID, VOLTER_ORG_CLIENT_SECRET, VOLTER_TEAM_ORGS
|
|
10
|
-
// optional: this server's product credential at the organization's identity issuer, and the teams whose
|
|
11
|
-
// organization there admits its members with no invitation (`team=slug,…`)
|
|
12
9
|
// SUPERCODE_TEAMS_BOOTSTRAP_TOKEN secret: the owner's first credential, minted with
|
|
13
10
|
// `supercode teams server token`
|
|
14
11
|
import { DurableObject } from 'cloudflare:workers';
|
|
@@ -18,7 +15,6 @@ import { createHttpHandler, authenticate } from '@volter/teams/server/http';
|
|
|
18
15
|
import { harnessRoutes } from '../server/harness-routes.mjs';
|
|
19
16
|
import { authorizeMachineUpgrade, acceptMachineSocket, LINK_PROTOCOL, CHANNEL_PROTOCOL } from '@volter/teams/server/machine-link';
|
|
20
17
|
import { setOtlpSchemaSources } from '../server/otlp/transport.mjs';
|
|
21
|
-
import { identityOrganizationsFromEnv } from '@volter/teams/server/identity-orgs';
|
|
22
18
|
import { setViewerSources } from '@volter/teams/server/viewer';
|
|
23
19
|
import { serveFetch } from './fetch-adapter.mjs';
|
|
24
20
|
import { webSocketDuplex } from './websocket-duplex.mjs';
|
|
@@ -80,7 +76,7 @@ export class TeamsServer extends DurableObject {
|
|
|
80
76
|
console.log(`storage: database_bytes=${ctx.storage.sql.databaseSize}`);
|
|
81
77
|
const db = durableSql(ctx.storage);
|
|
82
78
|
migrate({ exec: db.exec, tx: db.tx, version: () => ctx.storage.kv.get('schema_version') ?? 0, setVersion: (n) => ctx.storage.kv.put('schema_version', n) });
|
|
83
|
-
this.api = createTeamsApi({ publicOrigin: normalizePublicOrigin(env.PUBLIC_ORIGIN), appRootSecret: env.SUPERCODE_TEAMS_BOOTSTRAP_TOKEN || null
|
|
79
|
+
this.api = createTeamsApi({ publicOrigin: normalizePublicOrigin(env.PUBLIC_ORIGIN), appRootSecret: env.SUPERCODE_TEAMS_BOOTSTRAP_TOKEN || null });
|
|
84
80
|
this.api.storageUsage = () => {
|
|
85
81
|
const sql = ctx.storage.sql;
|
|
86
82
|
let freeBytes = null;
|
package/workspace-client.d.ts
CHANGED
|
@@ -54,6 +54,8 @@ export interface AppDelegation { actions:string[]; actor:string; expires_in_ms?:
|
|
|
54
54
|
export interface AppToken { grants?:AppResourceGrant[]|null; token:string; kind:'service'; expires_at:number; actions:string[]; subject:string; act:string[] }
|
|
55
55
|
export interface AppCodeExchange { version:1; installation:AppInstallation; credential:AppToken; webhook_secret:string }
|
|
56
56
|
export interface TeamClient {
|
|
57
|
+
identity: import('@volter/teams/http-client').TeamClient['identity'];
|
|
58
|
+
events:{catalogue(options?:RequestOptions):Promise<Result<{kinds:string[]}>>;createSource(body:{name:string},options?:RequestOptions):Promise<Result<{id:string;name:string}>>;publish(source:string,body:{id:string;kind:string;resource:{kind:'task'|'agent';id:string};revision:number;data:Record<string,unknown>;actor?:{session?:string|null}},options?:RequestOptions):Promise<Result<OntologyEvent>>;follow(query?:{source?:string;kinds?:string[]},options?:StreamOptions):AsyncIterable<PublishedEventFrame>};
|
|
57
59
|
apps:{deregister(id:string,options?:RequestOptions):Promise<null>;catalogue(options?:RequestOptions):Promise<Result<{actions:string[];event_kinds:string[]}>>;list(options?:RequestOptions):Promise<Page<TeamsApp>>;get(id:string,options?:RequestOptions):Promise<Result<TeamsApp>>;register(body:RegisterTeamsApp,options?:RequestOptions):Promise<Result<TeamsApp & {secret?:string}>>;rotateSecret(id:string,options?:RequestOptions):Promise<Result<{id:string;secret?:string}>>;install(id:string,body:{redirect_url:string;state:string;actions?:string[]},options?:RequestOptions):Promise<Result<{installation:AppInstallation;code?:string;state:string;redirect_url:string;expires_at:number}>>};
|
|
58
60
|
appInstallations:{list(options?:RequestOptions):Promise<Page<AppInstallation>>;get(id:string,options?:RequestOptions):Promise<Result<AppInstallation>>;uninstall(id:string,options?:RequestOptions):Promise<null>;delegate(id:string,body:AppDelegation,options?:RequestOptions):Promise<Result<AppToken|null>>};
|
|
59
61
|
sessions:{list(query?:ListQuery,options?:RequestOptions):Promise<Page<Descriptor>>;search(query:SearchQuery,options?:RequestOptions):Promise<SearchPage>;watch(query?:ListQuery,options?:StreamOptions):AsyncIterable<Event>;get(id:string):SessionClient};
|
|
@@ -78,6 +80,8 @@ export interface TeamClient {
|
|
|
78
80
|
stream(attachment:Attachment,options?:Pick<RequestOptions,'signal'>):AsyncIterable<AttachmentFrame>;
|
|
79
81
|
};
|
|
80
82
|
}
|
|
83
|
+
export interface OntologyEvent { id:string|null;org:string;at:number|null;actor:{principal:string|null;session:string|null;lineage:string[]}|null;kind:string;resource:{kind:string;id:string};data:Record<string,unknown> }
|
|
84
|
+
export interface PublishedEventFrame { type:'snapshot_begin'|'snapshot_item'|'snapshot_end'|'event'|'checkpoint'|'access_revoked'|'resync_required';cursor?:string;sequence?:number;data?:OntologyEvent;terminal?:boolean;reason?:string }
|
|
81
85
|
export interface SessionClient {
|
|
82
86
|
artifact(uploadId:string,options?:{signal?:AbortSignal;range?:string}):Promise<Response>;
|
|
83
87
|
descriptor(options?:RequestOptions):Promise<Result<Descriptor>>;
|
package/workspace-client.mjs
CHANGED
|
@@ -1,185 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
export class TeamsClient {
|
|
4
|
-
constructor({server,credential,fetch:fetchImpl=(...args)=>globalThis.fetch(...args)}){this.server=trimOrigin(server);this.credential=credential;this.fetch=fetchImpl;if(typeof fetchImpl!=='function')throw new TypeError('fetch is required');}
|
|
5
|
-
async request(method,path,{body,signal,idempotencyKey,ifMatch}={}) {
|
|
6
|
-
const key=idempotencyKey??(method==='GET'?null:randomKey());let delay=250,last;
|
|
7
|
-
for(let attempt=0;attempt<5;attempt++) { try {
|
|
8
|
-
const response=await this.fetch(this.server+path,{method,signal,redirect:'manual',headers:{Accept:'application/json',Authorization:`Bearer ${this.credential}`,...(body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{}),...(ifMatch!==undefined?{'If-Match':String(ifMatch)}:{})},body:body===undefined?undefined:JSON.stringify(body)});
|
|
9
|
-
if(response.type==='opaqueredirect'||(response.status>=300&&response.status<400))throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
|
|
10
|
-
const value=response.status===204?null:await response.json();if(!response.ok)throw Object.assign(new Error(value?.error?.message??`HTTP ${response.status}`),value?.error,{status:response.status});return value;
|
|
11
|
-
} catch(error) { last=error;if(method!=='GET'||signal?.aborted||!(!error.status||error.retryable)||attempt===4)throw error;await retryWait(delay,signal);delay=Math.min(delay*2,5000); } }
|
|
12
|
-
throw last;
|
|
13
|
-
}
|
|
14
|
-
me(options){return this.request('GET','/api/v1/me',options);} capabilities(options){return this.request('GET','/api/v1/capabilities',options);} team(id){return new TeamClient(this,id);}
|
|
15
|
-
redeemAppCode(body,options){return this.request('POST','/api/v1/apps/token',{...options,body});}
|
|
16
|
-
teams(queryValue={},options){return this.request('GET','/api/v1/teams'+query(queryValue),options);}
|
|
17
|
-
createTeam(body,options){return this.request('POST','/api/v1/teams',{...options,body});}
|
|
18
|
-
}
|
|
19
|
-
const segment = value => {
|
|
20
|
-
if (typeof value !== 'string' || !value || value.length > 256) throw new TypeError('a nonempty resource ID is required');
|
|
21
|
-
return encodeURIComponent(value);
|
|
22
|
-
};
|
|
23
|
-
class TeamClient {
|
|
24
|
-
constructor(root, id) {
|
|
25
|
-
this.root = root; this.id = id;
|
|
26
|
-
const base = `/api/v1/teams/${segment(id)}`;
|
|
27
|
-
const get = (path, options) => root.request('GET', base + path, options);
|
|
28
|
-
const post = (path, body, options) => root.request('POST', base + path, { ...options, body });
|
|
29
|
-
const patch = (path, body, options) => root.request('PATCH', base + path, { ...options, body });
|
|
30
|
-
const remove = (path, options) => root.request('DELETE', base + path, options);
|
|
31
|
-
const resource = (path, id) => `${path}/${segment(id)}`;
|
|
32
|
-
this.apps = {
|
|
33
|
-
catalogue: o => get('/apps/catalogue', o),
|
|
34
|
-
list: o => get('/apps', o), get: (id, o) => get(resource('/apps', id), o),
|
|
35
|
-
register: (body, o) => post('/apps', body, o),
|
|
36
|
-
deregister: (id, o) => remove(resource('/apps', id), o),
|
|
37
|
-
rotateSecret: (id, o) => post(resource('/apps', id) + '/secret', {}, o),
|
|
38
|
-
install: (id, body, o) => post(resource('/apps', id) + '/installations', body, o),
|
|
39
|
-
};
|
|
40
|
-
this.appInstallations = {
|
|
41
|
-
list: o => get('/app-installations', o), get: (id, o) => get(resource('/app-installations', id), o),
|
|
42
|
-
uninstall: (id, o) => remove(resource('/app-installations', id), o),
|
|
43
|
-
delegate: (id, body, o) => post(resource('/app-installations', id) + '/delegations', body, o),
|
|
44
|
-
};
|
|
45
|
-
this.sessions = {
|
|
46
|
-
list: (q = {}, o) => get('/sessions' + query(q), o),
|
|
47
|
-
search: (q, o) => get('/sessions/search' + query(q), o),
|
|
48
|
-
watch: (q = {}, o = {}) => events(root, base + '/sessions/events' + query(q), o),
|
|
49
|
-
get: id => new SessionClient(root, base, id),
|
|
50
|
-
};
|
|
51
|
-
this.members = {
|
|
52
|
-
list: (q = {}, o) => get('/members' + query(q), o),
|
|
53
|
-
update: (id, body, o) => patch(resource('/members', id), body, o),
|
|
54
|
-
remove: (id, o) => remove(resource('/members', id), o),
|
|
55
|
-
};
|
|
56
|
-
this.groups = {
|
|
57
|
-
list: (q = {}, o) => get('/groups' + query(q), o),
|
|
58
|
-
create: (body, o) => post('/groups', body, o),
|
|
59
|
-
remove: (id, o) => remove(resource('/groups', id), o),
|
|
60
|
-
addMember: (id, member, o) => root.request('PUT', base + resource('/groups', id) + resource('/members', member), o),
|
|
61
|
-
removeMember: (id, member, o) => remove(resource('/groups', id) + resource('/members', member), o),
|
|
62
|
-
};
|
|
63
|
-
this.machines = {
|
|
64
|
-
list: (q = {}, o) => get('/machines' + query(q), o),
|
|
65
|
-
get: (id, o) => get(resource('/machines', id), o),
|
|
66
|
-
create: (body, o) => post('/machines', body, o),
|
|
67
|
-
roots: (id, q = {}, o) => get(resource('/machines', id) + '/roots' + query(q), o),
|
|
68
|
-
remove: (id, o) => remove(resource('/machines', id), o),
|
|
69
|
-
liveGrants: machineId => {
|
|
70
|
-
const path = resource('/machines', machineId) + '/grants';
|
|
71
|
-
return {
|
|
72
|
-
list: (o) => get(path, o), get: (id, o) => get(resource(path, id), o),
|
|
73
|
-
create: (body, o) => post(path, body, o), remove: (id, o) => remove(resource(path, id), o),
|
|
74
|
-
};
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
this.sources = {
|
|
78
|
-
list: (q = {}, o) => get('/sources' + query(q), o),
|
|
79
|
-
get: (id, o) => get(resource('/sources', id), o),
|
|
80
|
-
create: (body, o) => post('/sources', body, o),
|
|
81
|
-
update: (id, body, o) => patch(resource('/sources', id), body, o),
|
|
82
|
-
credential: (id, body, o) => post(resource('/sources', id) + '/credentials', body, o),
|
|
83
|
-
configuration: (id, format = 'otel-collector', o) => get(resource('/sources', id) + '/config' + query({ format }), o),
|
|
84
|
-
};
|
|
85
|
-
this.syncRules = {
|
|
86
|
-
list: (q = {}, o) => get('/sync-rules' + query(q), o), get: (id, o) => get(resource('/sync-rules', id), o),
|
|
87
|
-
create: (body, o) => post('/sync-rules', body, o), update: (id, body, o) => patch(resource('/sync-rules', id), body, o),
|
|
88
|
-
remove: (id, o) => remove(resource('/sync-rules', id), o),
|
|
89
|
-
};
|
|
90
|
-
this.credentials = {
|
|
91
|
-
create: (body, o) => post('/credentials', body, o), remove: (id, o) => remove(resource('/credentials', id), o),
|
|
92
|
-
};
|
|
93
|
-
this.invitations = { create: (body, o) => post('/invitations', body, o) };
|
|
94
|
-
this.operations = { get: (id, o) => get(resource('/operations', id), o) };
|
|
95
|
-
this.continuations = {
|
|
96
|
-
get: (id, o) => get(resource('/continuations', id), o),
|
|
97
|
-
authorizeDestination: (id, body, o) => post(resource('/continuations', id) + '/authorize-destination', body, o),
|
|
98
|
-
launch: (id, o) => post(resource('/continuations', id) + '/launch', undefined, o),
|
|
99
|
-
completeLocal: (id, body, o) => post(resource('/continuations', id) + '/complete-local', body, o),
|
|
100
|
-
};
|
|
101
|
-
this.attachments = {
|
|
102
|
-
actions: (id, body, o) => post(resource('/attachments', id) + '/actions', body, o),
|
|
103
|
-
detach: (id, o) => remove(resource('/attachments', id), o),
|
|
104
|
-
stream: (attachment, o = {}) => attachmentEvents(root, base, attachment, o),
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
class SessionClient {
|
|
109
|
-
constructor(root, base, id) { this.root = root; this.path = `${base}/sessions/${segment(id)}`; }
|
|
110
|
-
descriptor = o => this.root.request('GET', this.path, o);
|
|
111
|
-
read = (q = {}, o) => this.root.request('GET', this.path + '/messages' + query(q), o);
|
|
112
|
-
follow = (q = {}, o = {}) => events(this.root, this.path + '/events' + query(q), o);
|
|
113
|
-
access = o => this.root.request('GET', this.path + '/access', o);
|
|
114
|
-
grant = (body, o) => this.root.request('POST', this.path + '/access', { ...o, body });
|
|
115
|
-
revoke = (id, o) => this.root.request('DELETE', this.path + '/access/' + segment(id), o);
|
|
116
|
-
export = (body, o) => this.root.request('POST', this.path + '/exports', { ...o, body });
|
|
117
|
-
prepare = (body, o) => this.root.request('POST', this.path + '/continuations', { ...o, body });
|
|
118
|
-
attach = (body, o) => this.root.request('POST', this.path + '/attachments', { ...o, body });
|
|
119
|
-
artifact = async (id, { signal, range } = {}) => {
|
|
120
|
-
if (range !== undefined && !/^bytes=(?:[0-9]+-[0-9]*|-[0-9]+)$/.test(range)) throw new TypeError('range must be one byte range');
|
|
121
|
-
const response = await this.root.fetch(this.root.server + this.path + '/artifacts/' + segment(id), {
|
|
122
|
-
signal, redirect: 'manual', headers: { Authorization: `Bearer ${this.root.credential}`, ...(range ? { Range: range } : {}) },
|
|
123
|
-
});
|
|
124
|
-
rejectRedirect(response);
|
|
125
|
-
if (!response.ok) throw await sseError(response);
|
|
126
|
-
return response;
|
|
127
|
-
};
|
|
128
|
-
}
|
|
129
|
-
function rejectRedirect(response) {
|
|
130
|
-
if (response.type === 'opaqueredirect' || response.status >= 300 && response.status < 400) throw Object.assign(new Error('cross-origin redirects are refused'), { code: 'invalid_request', status: 400, retryable: false });
|
|
131
|
-
}
|
|
132
|
-
async function* attachmentEvents(root, base, attachment, { signal } = {}) {
|
|
133
|
-
const expected = root.server + base + '/attachments/' + segment(attachment.id) + '/stream';
|
|
134
|
-
if (attachment.stream_format !== 'observed_runtime_event' || attachment.stream_url !== expected || typeof attachment.credential !== 'string' || !attachment.credential) throw new TypeError('attachment stream target or credential is invalid');
|
|
135
|
-
let response;
|
|
136
|
-
try {
|
|
137
|
-
response = await root.fetch(expected, { signal, redirect: 'manual', headers: { Accept: 'text/event-stream', Authorization: `Bearer ${attachment.credential}` } });
|
|
138
|
-
rejectRedirect(response);
|
|
139
|
-
if (!response.ok) throw await sseError(response);
|
|
140
|
-
let buffer = '';
|
|
141
|
-
for await (const chunk of response.body.pipeThrough(new TextDecoderStream())) {
|
|
142
|
-
buffer += chunk;
|
|
143
|
-
let match;
|
|
144
|
-
while ((match = /\r?\n\r?\n/.exec(buffer))) {
|
|
145
|
-
const block = buffer.slice(0, match.index); buffer = buffer.slice(match.index + match[0].length);
|
|
146
|
-
if (new TextEncoder().encode(block).length > 1024 * 1024 + 4096) throw Object.assign(new Error('attachment frame exceeds 1 MiB'), { code: 'resync_required', retryable: false });
|
|
147
|
-
const lines = block.split(/\r?\n/), kind = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
|
|
148
|
-
const data = lines.filter(line => line.startsWith('data:')).map(line => line.slice(5).replace(/^ /, '')).join('\n');
|
|
149
|
-
if (!data) continue;
|
|
150
|
-
if (kind === 'access_revoked' || kind === 'resync_required') throw Object.assign(new Error(`attachment stream ${kind}`), { code: kind, retryable: false });
|
|
151
|
-
if (kind !== 'runtime') throw Object.assign(new Error('unexpected attachment stream event'), { code: 'invalid_request', retryable: false });
|
|
152
|
-
const frame = JSON.parse(data);
|
|
153
|
-
yield frame;
|
|
154
|
-
if (frame.type === 'closed' || frame.type === 'error' && frame.terminal) return;
|
|
155
|
-
}
|
|
156
|
-
if (new TextEncoder().encode(buffer).length > 1024 * 1024 + 4096) throw Object.assign(new Error('attachment frame exceeds 1 MiB'), { code: 'resync_required', retryable: false });
|
|
157
|
-
}
|
|
158
|
-
if (!signal?.aborted) throw Object.assign(new Error('attachment stream ended; create a new attachment to reconnect'), { code: 'resync_required', retryable: false });
|
|
159
|
-
} catch (error) { if (!signal?.aborted) throw error; }
|
|
160
|
-
}
|
|
161
|
-
const query=q=>{const p=new URLSearchParams();for(const [k,v]of Object.entries(q))if(v!==undefined)p.set(k,String(v));return p.size?'?'+p:'';};
|
|
162
|
-
const retryWait=(delay,signal)=>new Promise((resolve,reject)=>{const done=()=>{signal?.removeEventListener('abort',abort);resolve();},timer=setTimeout(done,delay),abort=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);reject(signal.reason);};signal?.addEventListener('abort',abort,{once:true});});
|
|
163
|
-
async function sseError(response){let value=null;try{value=await response.json();}catch{}return Object.assign(new Error(value?.error?.message??`SSE HTTP ${response.status}`),value?.error,{status:response.status,retryable:value?.error?.retryable??(response.status===429||response.status>=500)});}
|
|
164
|
-
async function* events(root,path,{signal,after}={}){
|
|
165
|
-
let cursor=after??null,lastYielded=null,lastError=null;
|
|
166
|
-
for(let attempt=0;attempt<5&&!signal?.aborted;attempt++){
|
|
167
|
-
const suffix=cursor?path+(path.includes('?')?'&':'?')+'after='+encodeURIComponent(cursor):path;
|
|
168
|
-
try{
|
|
169
|
-
const response=await root.fetch(root.server+suffix,{signal,redirect:'manual',headers:{Accept:'text/event-stream',Authorization:`Bearer ${root.credential}`,...(cursor?{'Last-Event-ID':cursor}:{})}});
|
|
170
|
-
if(response.type==='opaqueredirect'||response.status>=300&&response.status<400)throw Object.assign(new Error('cross-origin redirects are refused'),{code:'invalid_request',status:400,retryable:false});
|
|
171
|
-
if(!response.ok)throw await sseError(response);
|
|
172
|
-
let buffer='';
|
|
173
|
-
for await(const chunk of response.body.pipeThrough(new TextDecoderStream())){
|
|
174
|
-
buffer+=chunk;let at;
|
|
175
|
-
while((at=buffer.indexOf('\n\n'))>=0){
|
|
176
|
-
const block=buffer.slice(0,at);buffer=buffer.slice(at+2);const id=block.split('\n').find(line=>line.startsWith('id: '))?.slice(4),data=block.split('\n').filter(line=>line.startsWith('data: ')).map(line=>line.slice(6)).join('\n');if(!data)continue;
|
|
177
|
-
const event=JSON.parse(data),terminal=event?.terminal||event?.type==='access_revoked'||event?.type==='resync_required',nextCursor=id??cursor;cursor=nextCursor;if(!terminal&&nextCursor&&nextCursor===lastYielded)continue;if(nextCursor)lastYielded=nextCursor;yield event;if(terminal)return;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
lastError=Object.assign(new Error('SSE stream ended before a terminal event'),{retryable:true});
|
|
181
|
-
}catch(error){if(signal?.aborted)return;if(error.retryable===false||error.status&&error.status!==429&&error.status<500)throw error;lastError=error;}
|
|
182
|
-
if(attempt<4)await retryWait(Math.min(250*2**attempt,5000),signal).catch(()=>{});
|
|
183
|
-
}
|
|
184
|
-
if(!signal?.aborted&&lastError)throw lastError;
|
|
185
|
-
}
|
|
1
|
+
// The Teams HTTP door has one implementation in the neutral Teams package.
|
|
2
|
+
export { TeamsClient } from "@volter/teams/http-client";
|
package/lines.mjs
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/** Splits a stream's chunks into its complete lines, in time linear in its length: each chunk is scanned once and a
|
|
2
|
-
* line joined once, when its newline arrives. Appending to one buffer and searching it from the start again on
|
|
3
|
-
* every chunk is quadratic in a long line, and one answer (a large session's `sessions.load`, a whole machine's
|
|
4
|
-
* `sessions.discover`) is a line of tens of megabytes. */
|
|
5
|
-
export function lineSplitter() {
|
|
6
|
-
let pending = [];
|
|
7
|
-
return (chunk) => {
|
|
8
|
-
const text = String(chunk), lines = [];
|
|
9
|
-
let from = 0, at;
|
|
10
|
-
while ((at = text.indexOf('\n', from)) >= 0) {
|
|
11
|
-
pending.push(text.slice(from, at));
|
|
12
|
-
lines.push(pending.join(''));
|
|
13
|
-
pending = [];
|
|
14
|
-
from = at + 1;
|
|
15
|
-
}
|
|
16
|
-
if (from < text.length) pending.push(text.slice(from));
|
|
17
|
-
return lines;
|
|
18
|
-
};
|
|
19
|
-
}
|