@prjct.app/pi-team 0.6.0 → 0.7.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/CHANGELOG.md +71 -0
- package/CONTRIBUTING.md +2 -1
- package/README.md +23 -177
- package/docs/architecture.md +36 -168
- package/package.json +10 -4
- package/src/commands/team-command.ts +37 -0
- package/src/domain/lease.ts +54 -0
- package/src/domain/member.ts +58 -0
- package/src/domain/message.ts +91 -0
- package/src/domain/request.ts +67 -0
- package/src/domain/team.ts +71 -0
- package/src/dynamic/domain.ts +110 -0
- package/src/dynamic/memory.ts +38 -0
- package/src/dynamic/panel.ts +155 -0
- package/src/dynamic/peer-log.ts +39 -0
- package/src/dynamic/runner.ts +196 -0
- package/src/dynamic/service.ts +292 -0
- package/src/dynamic/store.ts +57 -0
- package/src/dynamic/view.ts +21 -0
- package/src/dynamic/worker.ts +210 -0
- package/src/dynamic/workspace.ts +43 -0
- package/src/index.ts +204 -679
- package/src/process-identity.ts +68 -0
- package/src/runtime/delivery.ts +326 -0
- package/src/runtime/membership.ts +212 -0
- package/src/runtime/presence.ts +98 -0
- package/src/runtime/purge.ts +39 -0
- package/src/runtime/reconciler.ts +112 -0
- package/src/runtime/requests.ts +353 -0
- package/src/runtime/resources.ts +117 -0
- package/src/runtime/team-runtime.ts +47 -0
- package/src/runtime/team-tool.ts +191 -0
- package/src/storage/atomic.ts +347 -0
- package/src/storage/inbox-store.ts +290 -0
- package/src/storage/lease-store.ts +158 -0
- package/src/storage/paths.ts +76 -0
- package/src/storage/receipt-store.ts +117 -0
- package/src/storage/team-store.ts +190 -0
- package/src/supervisor/control-protocol.ts +125 -0
- package/src/supervisor/runtime-store.ts +231 -0
- package/src/supervisor/shutdown.ts +141 -0
- package/src/supervisor/supervisor.ts +657 -0
- package/src/supervisor/tmux-adapter.ts +192 -0
- package/src/supervisor/worker-bootstrap.ts +43 -0
- package/src/supervisor/worker-client.ts +233 -0
- package/src/ui/team-dashboard.ts +179 -0
- package/src/mailbox.ts +0 -536
- package/src/schema.ts +0 -25
- package/src/store.ts +0 -230
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { isAbsolute } from 'node:path';
|
|
4
|
+
import { defaultProcessController, type ProcessController, type ProcessIdentity } from '../process-identity.ts';
|
|
5
|
+
import type { OwnerIdentity, OwnedRuntime } from './runtime-store.ts';
|
|
6
|
+
|
|
7
|
+
export const TMUX_RUNTIME_ID = '@pi-team-runtime-id';
|
|
8
|
+
export const TMUX_OWNER_INSTANCE = '@pi-team-owner-instance';
|
|
9
|
+
export const TMUX_TOKEN_HASH = '@pi-team-token-hash';
|
|
10
|
+
|
|
11
|
+
const ENV_RUNTIME_ID = 'PI_TEAM_RUNTIME_ID';
|
|
12
|
+
const ENV_OWNER_INSTANCE = 'PI_TEAM_OWNER_INSTANCE';
|
|
13
|
+
const ENV_OWNER_PROCESS_NONCE = 'PI_TEAM_OWNER_PROCESS_NONCE';
|
|
14
|
+
const ENV_TOKEN_HASH = 'PI_TEAM_TOKEN_HASH';
|
|
15
|
+
const ENV_TEAM_ID = 'PI_TEAM_TEAM_ID';
|
|
16
|
+
const ENV_MEMBER_ID = 'PI_TEAM_MEMBER_ID';
|
|
17
|
+
const ENV_MEMBER_ALIAS = 'PI_TEAM_MEMBER_ALIAS';
|
|
18
|
+
const ENV_MEMBER_SESSION = 'PI_TEAM_MEMBER_SESSION';
|
|
19
|
+
const ENV_MEMBER_GENERATION = 'PI_TEAM_MEMBER_GENERATION';
|
|
20
|
+
const ENV_MEMBER_LEASE_TOKEN = 'PI_TEAM_MEMBER_LEASE_TOKEN';
|
|
21
|
+
const ENV_MEMBER_LEASE_GENERATION = 'PI_TEAM_MEMBER_LEASE_GENERATION';
|
|
22
|
+
const ENV_AUTO_REQUESTS = 'PI_TEAM_AUTO_REQUESTS';
|
|
23
|
+
const ENV_CONTROL_SOCKET = 'PI_TEAM_CONTROL_SOCKET';
|
|
24
|
+
const ENV_CONTROL_TOKEN = 'PI_TEAM_CONTROL_TOKEN';
|
|
25
|
+
|
|
26
|
+
export type TmuxCommandResult = { readonly stdout: string; readonly stderr: string };
|
|
27
|
+
export type TmuxCommand = (program: string, args: readonly string[], cwd?: string) => Promise<TmuxCommandResult>;
|
|
28
|
+
|
|
29
|
+
export type LaunchedTmuxRuntime = {
|
|
30
|
+
readonly session: string;
|
|
31
|
+
readonly identity: ProcessIdentity;
|
|
32
|
+
readonly ownershipTokenHash: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type TmuxLaunchOptions = {
|
|
36
|
+
readonly runtimeId: string;
|
|
37
|
+
readonly owner: OwnerIdentity;
|
|
38
|
+
readonly cwd: string;
|
|
39
|
+
readonly command: readonly [string, ...string[]];
|
|
40
|
+
readonly environment?: Readonly<Record<string, string | undefined>>;
|
|
41
|
+
readonly controlSocket: string;
|
|
42
|
+
readonly controlToken: string;
|
|
43
|
+
readonly ownershipToken: string;
|
|
44
|
+
readonly workerMembership: {
|
|
45
|
+
readonly teamId: string;
|
|
46
|
+
readonly memberId: string;
|
|
47
|
+
readonly alias: string;
|
|
48
|
+
readonly sessionId: string;
|
|
49
|
+
readonly memberGeneration: number;
|
|
50
|
+
readonly leaseToken: string;
|
|
51
|
+
readonly leaseGeneration: number;
|
|
52
|
+
};
|
|
53
|
+
readonly autoRequests: boolean;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const defaultCommand: TmuxCommand = (program, args, cwd) => new Promise((resolvePromise, reject) => {
|
|
57
|
+
execFile(program, [...args], { cwd, env: process.env, timeout: 10_000 }, (error, stdout, stderr) => {
|
|
58
|
+
if (error) { reject(error); return; }
|
|
59
|
+
resolvePromise({ stdout: String(stdout), stderr: String(stderr) });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* An exact-match session target. tmux 3.6 resolves a bare "=name" as a session
|
|
65
|
+
* for some commands but not for set-option or display-message, which then fail
|
|
66
|
+
* with "no such session"; "=name:" (session, current window) works for all.
|
|
67
|
+
*/
|
|
68
|
+
export const exact = (session: string): string => `=${session}:`;
|
|
69
|
+
|
|
70
|
+
function hashToken(token: string): string { return createHash('sha256').update(token).digest('hex'); }
|
|
71
|
+
|
|
72
|
+
function sessionName(runtimeId: string): string {
|
|
73
|
+
return `pi-team-v2-${createHash('sha256').update(runtimeId).digest('hex').slice(0, 24)}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parsePid(value: string): number | undefined {
|
|
77
|
+
const processPid = Number(value.trim());
|
|
78
|
+
return Number.isSafeInteger(processPid) && processPid > 1 ? processPid : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function environment(output: string): ReadonlyMap<string, string> {
|
|
82
|
+
return new Map(output.split('\n').filter(line => line.includes('=')).map(line => {
|
|
83
|
+
const separator = line.indexOf('=');
|
|
84
|
+
return [line.slice(0, separator), line.slice(separator + 1)];
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const ENVIRONMENT_MAX_BYTES = 256 * 1024;
|
|
89
|
+
const TMUX_MANAGED_ENVIRONMENT = new Set(['TMUX', 'TMUX_PANE', 'TERM', 'TERM_PROGRAM', 'TERM_PROGRAM_VERSION',
|
|
90
|
+
'PWD', 'OLDPWD', 'SHLVL', '_']);
|
|
91
|
+
function clientEnvironment(source: TmuxLaunchOptions['environment']): readonly string[] {
|
|
92
|
+
const entries = Object.entries(source ?? {}).filter((entry): entry is [string, string] =>
|
|
93
|
+
entry[1] !== undefined && /^[A-Za-z_][A-Za-z0-9_]*$/.test(entry[0]) && !entry[0].startsWith('PI_TEAM_') &&
|
|
94
|
+
!TMUX_MANAGED_ENVIRONMENT.has(entry[0]))
|
|
95
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
96
|
+
.map(([key, value]) => `${key}=${value}`);
|
|
97
|
+
if (Buffer.byteLength(entries.join('\0'), 'utf8') > ENVIRONMENT_MAX_BYTES) {
|
|
98
|
+
throw new Error('Supervised runtime environment exceeds its byte limit.');
|
|
99
|
+
}
|
|
100
|
+
return entries.flatMap(value => ['-e', value]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class TmuxAdapter {
|
|
104
|
+
constructor(
|
|
105
|
+
private readonly run: TmuxCommand = defaultCommand,
|
|
106
|
+
private readonly processes: ProcessController = defaultProcessController,
|
|
107
|
+
) {}
|
|
108
|
+
|
|
109
|
+
async available(): Promise<boolean> {
|
|
110
|
+
return this.run('tmux', ['-V']).then(() => true, () => false);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async launch(options: TmuxLaunchOptions): Promise<LaunchedTmuxRuntime> {
|
|
114
|
+
if (!isAbsolute(options.cwd)) throw new Error('Supervised runtime cwd must be absolute.');
|
|
115
|
+
if (options.controlToken.length < 64 || options.ownershipToken.length < 64) throw new Error('Runtime control tokens are too short.');
|
|
116
|
+
const session = sessionName(options.runtimeId);
|
|
117
|
+
const tokenHash = hashToken(options.ownershipToken);
|
|
118
|
+
const target = exact(session);
|
|
119
|
+
const args = [
|
|
120
|
+
'new-session', '-d', '-s', session, '-c', options.cwd,
|
|
121
|
+
...clientEnvironment(options.environment),
|
|
122
|
+
'-e', `${ENV_RUNTIME_ID}=${options.runtimeId}`,
|
|
123
|
+
'-e', `${ENV_OWNER_INSTANCE}=${options.owner.ownerInstanceId}`,
|
|
124
|
+
'-e', `${ENV_OWNER_PROCESS_NONCE}=${options.owner.ownerProcessNonce}`,
|
|
125
|
+
'-e', `${ENV_TOKEN_HASH}=${tokenHash}`,
|
|
126
|
+
'-e', `${ENV_TEAM_ID}=${options.workerMembership.teamId}`,
|
|
127
|
+
'-e', `${ENV_MEMBER_ID}=${options.workerMembership.memberId}`,
|
|
128
|
+
'-e', `${ENV_MEMBER_ALIAS}=${options.workerMembership.alias}`,
|
|
129
|
+
'-e', `${ENV_MEMBER_SESSION}=${options.workerMembership.sessionId}`,
|
|
130
|
+
'-e', `${ENV_MEMBER_GENERATION}=${options.workerMembership.memberGeneration}`,
|
|
131
|
+
'-e', `${ENV_MEMBER_LEASE_TOKEN}=${options.workerMembership.leaseToken}`,
|
|
132
|
+
'-e', `${ENV_MEMBER_LEASE_GENERATION}=${options.workerMembership.leaseGeneration}`,
|
|
133
|
+
'-e', `${ENV_AUTO_REQUESTS}=${options.autoRequests ? '1' : '0'}`,
|
|
134
|
+
'-e', `${ENV_CONTROL_SOCKET}=${options.controlSocket}`,
|
|
135
|
+
'-e', `${ENV_CONTROL_TOKEN}=${options.controlToken}`,
|
|
136
|
+
'--', ...options.command,
|
|
137
|
+
];
|
|
138
|
+
await this.run('tmux', args, options.cwd);
|
|
139
|
+
try {
|
|
140
|
+
await this.run('tmux', ['set-option', '-t', target, TMUX_RUNTIME_ID, options.runtimeId]);
|
|
141
|
+
await this.run('tmux', ['set-option', '-t', target, TMUX_OWNER_INSTANCE, options.owner.ownerInstanceId]);
|
|
142
|
+
await this.run('tmux', ['set-option', '-t', target, TMUX_TOKEN_HASH, tokenHash]);
|
|
143
|
+
const pane = await this.run('tmux', ['display-message', '-p', '-t', target, '#{pane_pid}']);
|
|
144
|
+
const processPid = parsePid(pane.stdout);
|
|
145
|
+
const identity = processPid ? await this.processes.inspect(processPid) : undefined;
|
|
146
|
+
if (!identity) throw new Error('Could not verify the supervised runtime process identity.');
|
|
147
|
+
return { session, identity, ownershipTokenHash: tokenHash };
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (await this.environmentMatches(session, options.runtimeId, options.owner, tokenHash)) {
|
|
150
|
+
await this.run('tmux', ['kill-session', '-t', target]).catch(() => ({ stdout: '', stderr: '' }));
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async metadataMatches(runtime: OwnedRuntime): Promise<boolean> {
|
|
157
|
+
if (!runtime.tmuxSession || !runtime.tmuxOwnershipTokenHash) return false;
|
|
158
|
+
const format = `#{${TMUX_RUNTIME_ID}}\t#{${TMUX_OWNER_INSTANCE}}\t#{${TMUX_TOKEN_HASH}}`;
|
|
159
|
+
const output = await this.run('tmux', ['display-message', '-p', '-t', exact(runtime.tmuxSession), format])
|
|
160
|
+
.then(result => result.stdout.trim(), () => '');
|
|
161
|
+
return output === `${runtime.runtimeId}\t${runtime.owner.ownerInstanceId}\t${runtime.tmuxOwnershipTokenHash}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async environmentMatches(
|
|
165
|
+
session: string,
|
|
166
|
+
runtimeId: string,
|
|
167
|
+
owner: OwnerIdentity,
|
|
168
|
+
tokenHash: string,
|
|
169
|
+
): Promise<boolean> {
|
|
170
|
+
const values = await this.run('tmux', ['show-environment', '-t', exact(session)])
|
|
171
|
+
.then(result => environment(result.stdout), () => new Map<string, string>());
|
|
172
|
+
return values.get(ENV_RUNTIME_ID) === runtimeId && values.get(ENV_OWNER_INSTANCE) === owner.ownerInstanceId &&
|
|
173
|
+
values.get(ENV_OWNER_PROCESS_NONCE) === owner.ownerProcessNonce && values.get(ENV_TOKEN_HASH) === tokenHash;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async reassign(runtime: OwnedRuntime, owner: OwnerIdentity): Promise<void> {
|
|
177
|
+
if (!await this.metadataMatches(runtime) || !runtime.tmuxSession) {
|
|
178
|
+
throw Object.assign(new Error('Tmux ownership metadata does not permit handoff.'), { code: 'FENCED' });
|
|
179
|
+
}
|
|
180
|
+
await this.run('tmux', ['set-option', '-t', exact(runtime.tmuxSession), TMUX_OWNER_INSTANCE, owner.ownerInstanceId]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async killSession(runtime: OwnedRuntime): Promise<boolean> {
|
|
184
|
+
if (!runtime.tmuxSession || !await this.metadataMatches(runtime)) return false;
|
|
185
|
+
await this.run('tmux', ['kill-session', '-t', exact(runtime.tmuxSession)]);
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async sessionExists(session: string): Promise<boolean> {
|
|
190
|
+
return this.run('tmux', ['has-session', '-t', exact(session)]).then(() => true, () => false);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { WorkerClient, workerOptionsFromEnvironment, type WorkerClientOptions } from './worker-client.ts';
|
|
3
|
+
|
|
4
|
+
export type WorkerLifecycleContext = Pick<ExtensionContext, 'abort' | 'shutdown'>;
|
|
5
|
+
|
|
6
|
+
export class WorkerBootstrap {
|
|
7
|
+
private context?: WorkerLifecycleContext;
|
|
8
|
+
private readonly client: WorkerClient;
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
options: WorkerClientOptions,
|
|
12
|
+
createClient: (options: WorkerClientOptions) => WorkerClient = value => new WorkerClient(value),
|
|
13
|
+
) {
|
|
14
|
+
this.client = createClient({
|
|
15
|
+
...options,
|
|
16
|
+
hooks: {
|
|
17
|
+
abort: () => this.context?.abort(),
|
|
18
|
+
shutdown: () => this.context?.shutdown(),
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
attach(context: WorkerLifecycleContext): void { this.context = context; }
|
|
24
|
+
|
|
25
|
+
start(): Promise<void> {
|
|
26
|
+
if (!this.context) throw new Error('Worker lifecycle context must be attached before control starts.');
|
|
27
|
+
return this.client.start();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
busy(requestId: string): void { this.client.busy(requestId); }
|
|
31
|
+
|
|
32
|
+
ready(): void { this.client.ready(); }
|
|
33
|
+
|
|
34
|
+
dispose(): void { this.client.stop(); }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createWorkerBootstrap(
|
|
38
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
39
|
+
createClient?: (options: WorkerClientOptions) => WorkerClient,
|
|
40
|
+
): WorkerBootstrap | undefined {
|
|
41
|
+
const options = workerOptionsFromEnvironment({ abort: () => {}, shutdown: () => {} }, environment);
|
|
42
|
+
return options ? new WorkerBootstrap(options, createClient) : undefined;
|
|
43
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createConnection, type Socket } from 'node:net';
|
|
2
|
+
import {
|
|
3
|
+
CONTROL_PROTOCOL_VERSION, NdjsonFrameDecoder, assertSupervisorFrame, encodeControlFrame,
|
|
4
|
+
type SupervisorFrame, type WorkerFrame, type WorkerFramePayload,
|
|
5
|
+
} from './control-protocol.ts';
|
|
6
|
+
|
|
7
|
+
export type WorkerHooks = {
|
|
8
|
+
readonly abort: (requestId: string) => Promise<void> | void;
|
|
9
|
+
readonly shutdown: (reason: 'stop' | 'close' | 'reload_failed' | 'owner_lost') => Promise<void> | void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type WorkerClientOptions = {
|
|
13
|
+
readonly socketPath: string;
|
|
14
|
+
readonly runtimeId: string;
|
|
15
|
+
readonly controlToken: string;
|
|
16
|
+
readonly ownerProcessNonce: string;
|
|
17
|
+
readonly hooks: WorkerHooks;
|
|
18
|
+
readonly orphanMs?: number;
|
|
19
|
+
readonly reconnectMs?: number;
|
|
20
|
+
readonly now?: () => number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class WorkerClient {
|
|
24
|
+
private socket?: Socket;
|
|
25
|
+
private sequence = 0;
|
|
26
|
+
private activeRequestId?: string;
|
|
27
|
+
private authenticated = false;
|
|
28
|
+
private ownerEpoch?: number;
|
|
29
|
+
private stopped = false;
|
|
30
|
+
private orphaned = false;
|
|
31
|
+
private lastContact: number;
|
|
32
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
33
|
+
private watchdogTimer?: ReturnType<typeof setTimeout>;
|
|
34
|
+
private startPromise?: Promise<void>;
|
|
35
|
+
private shutdownPromise?: Promise<void>;
|
|
36
|
+
private startResolve?: () => void;
|
|
37
|
+
private startReject?: (error: Error) => void;
|
|
38
|
+
private readonly orphanMs: number;
|
|
39
|
+
private readonly reconnectMs: number;
|
|
40
|
+
private readonly now: () => number;
|
|
41
|
+
|
|
42
|
+
constructor(private readonly options: WorkerClientOptions) {
|
|
43
|
+
this.orphanMs = options.orphanMs ?? 15_000;
|
|
44
|
+
this.reconnectMs = options.reconnectMs ?? 250;
|
|
45
|
+
this.now = options.now ?? Date.now;
|
|
46
|
+
this.lastContact = this.now();
|
|
47
|
+
if (!Number.isFinite(this.orphanMs) || this.orphanMs <= 0 || !Number.isFinite(this.reconnectMs) || this.reconnectMs <= 0) {
|
|
48
|
+
throw new Error('Invalid worker watchdog timing.');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private nextSequence(): number {
|
|
53
|
+
this.sequence += 1;
|
|
54
|
+
return this.sequence;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private send(frame: WorkerFramePayload): boolean {
|
|
58
|
+
if (!this.socket || this.socket.destroyed) return false;
|
|
59
|
+
const value = {
|
|
60
|
+
version: CONTROL_PROTOCOL_VERSION,
|
|
61
|
+
runtimeId: this.options.runtimeId,
|
|
62
|
+
seq: this.nextSequence(),
|
|
63
|
+
...(frame.type === 'hello' ? {} : { ownerEpoch: this.ownerEpoch }),
|
|
64
|
+
...frame,
|
|
65
|
+
} as WorkerFrame;
|
|
66
|
+
this.socket.write(encodeControlFrame(value));
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
private clearReconnect(): void {
|
|
71
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
72
|
+
this.reconnectTimer = undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private scheduleReconnect(): void {
|
|
76
|
+
if (this.stopped || this.reconnectTimer) return;
|
|
77
|
+
const timer = setTimeout(() => {
|
|
78
|
+
this.reconnectTimer = undefined;
|
|
79
|
+
this.connect();
|
|
80
|
+
}, this.reconnectMs);
|
|
81
|
+
timer.unref();
|
|
82
|
+
this.reconnectTimer = timer;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private scheduleWatchdog(): void {
|
|
86
|
+
if (this.watchdogTimer) clearTimeout(this.watchdogTimer);
|
|
87
|
+
if (this.stopped) return;
|
|
88
|
+
const remaining = Math.max(0, this.orphanMs - (this.now() - this.lastContact));
|
|
89
|
+
const timer = setTimeout(() => { void this.checkWatchdog().catch(() => {}); }, remaining);
|
|
90
|
+
timer.unref();
|
|
91
|
+
this.watchdogTimer = timer;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async checkWatchdog(): Promise<void> {
|
|
95
|
+
if (this.stopped || this.now() - this.lastContact < this.orphanMs) {
|
|
96
|
+
this.scheduleWatchdog();
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (this.orphaned) return;
|
|
100
|
+
this.orphaned = true;
|
|
101
|
+
const requestId = this.activeRequestId;
|
|
102
|
+
this.activeRequestId = undefined;
|
|
103
|
+
try {
|
|
104
|
+
if (requestId) {
|
|
105
|
+
try { await this.options.hooks.abort(requestId); } catch {}
|
|
106
|
+
}
|
|
107
|
+
await this.options.hooks.shutdown('owner_lost');
|
|
108
|
+
} finally { this.stop(); }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private accept(frame: SupervisorFrame): void {
|
|
112
|
+
if (frame.runtimeId !== this.options.runtimeId) throw new Error('Supervisor frame targets another runtime.');
|
|
113
|
+
if (frame.type === 'hello_ack') {
|
|
114
|
+
if (frame.owner.ownerProcessNonce !== this.options.ownerProcessNonce) {
|
|
115
|
+
throw Object.assign(new Error('Supervisor process nonce does not match the worker owner.'), { code: 'FENCED' });
|
|
116
|
+
}
|
|
117
|
+
this.authenticated = true;
|
|
118
|
+
this.ownerEpoch = frame.owner.ownerEpoch;
|
|
119
|
+
this.lastContact = this.now();
|
|
120
|
+
this.scheduleWatchdog();
|
|
121
|
+
this.startResolve?.();
|
|
122
|
+
this.startResolve = undefined;
|
|
123
|
+
this.startReject = undefined;
|
|
124
|
+
if (this.activeRequestId) this.send({ type: 'state', state: 'busy', requestId: this.activeRequestId });
|
|
125
|
+
else this.send({ type: 'ready' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!this.authenticated) throw new Error('Supervisor sent a frame before authentication.');
|
|
129
|
+
this.lastContact = this.now();
|
|
130
|
+
this.scheduleWatchdog();
|
|
131
|
+
if (frame.type === 'ping') {
|
|
132
|
+
this.send({ type: 'pong', nonce: frame.nonce });
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (frame.type === 'cancel_request') {
|
|
136
|
+
if (this.activeRequestId === frame.requestId) void (async () => {
|
|
137
|
+
try { await this.options.hooks.abort(frame.requestId); } catch {}
|
|
138
|
+
})();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (frame.type === 'prepare_shutdown') {
|
|
142
|
+
void this.prepareShutdown(frame.reason).catch(() => {});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private prepareShutdown(reason: 'stop' | 'close' | 'reload_failed' | 'owner_lost'): Promise<void> {
|
|
147
|
+
if (this.shutdownPromise) return this.shutdownPromise;
|
|
148
|
+
const cleanup = (async () => {
|
|
149
|
+
const requestId = this.activeRequestId;
|
|
150
|
+
this.activeRequestId = undefined;
|
|
151
|
+
if (requestId) {
|
|
152
|
+
try { await this.options.hooks.abort(requestId); } catch {}
|
|
153
|
+
}
|
|
154
|
+
this.send({ type: 'shutdown_ack' });
|
|
155
|
+
await this.options.hooks.shutdown(reason);
|
|
156
|
+
})();
|
|
157
|
+
this.shutdownPromise = cleanup;
|
|
158
|
+
return cleanup;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
private connect(): void {
|
|
162
|
+
if (this.stopped) return;
|
|
163
|
+
this.clearReconnect();
|
|
164
|
+
this.authenticated = false;
|
|
165
|
+
this.ownerEpoch = undefined;
|
|
166
|
+
const decoder = new NdjsonFrameDecoder<SupervisorFrame>(assertSupervisorFrame);
|
|
167
|
+
const socket = createConnection({ path: this.options.socketPath });
|
|
168
|
+
this.socket = socket;
|
|
169
|
+
socket.on('connect', () => {
|
|
170
|
+
this.send({ type: 'hello', token: this.options.controlToken, ownerProcessNonce: this.options.ownerProcessNonce });
|
|
171
|
+
});
|
|
172
|
+
socket.on('data', chunk => {
|
|
173
|
+
try { for (const frame of decoder.push(chunk)) this.accept(frame); }
|
|
174
|
+
catch { socket.destroy(); }
|
|
175
|
+
});
|
|
176
|
+
socket.on('error', () => {});
|
|
177
|
+
socket.on('close', () => {
|
|
178
|
+
if (this.socket === socket) this.socket = undefined;
|
|
179
|
+
this.authenticated = false;
|
|
180
|
+
this.scheduleReconnect();
|
|
181
|
+
this.scheduleWatchdog();
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
start(): Promise<void> {
|
|
186
|
+
if (this.startPromise) return this.startPromise;
|
|
187
|
+
this.startPromise = new Promise<void>((resolvePromise, reject) => {
|
|
188
|
+
this.startResolve = resolvePromise;
|
|
189
|
+
this.startReject = reject;
|
|
190
|
+
});
|
|
191
|
+
this.scheduleWatchdog();
|
|
192
|
+
this.connect();
|
|
193
|
+
return this.startPromise;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
ready(): void {
|
|
197
|
+
this.activeRequestId = undefined;
|
|
198
|
+
if (this.authenticated) this.send({ type: 'state', state: 'ready' });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
busy(requestId: string): void {
|
|
202
|
+
this.activeRequestId = requestId;
|
|
203
|
+
if (this.authenticated) this.send({ type: 'state', state: 'busy', requestId });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
stop(): void {
|
|
207
|
+
if (this.stopped) return;
|
|
208
|
+
this.stopped = true;
|
|
209
|
+
this.clearReconnect();
|
|
210
|
+
if (this.watchdogTimer) clearTimeout(this.watchdogTimer);
|
|
211
|
+
this.watchdogTimer = undefined;
|
|
212
|
+
this.socket?.destroy();
|
|
213
|
+
this.socket = undefined;
|
|
214
|
+
if (!this.authenticated) this.startReject?.(new Error('Worker control client stopped before authentication.'));
|
|
215
|
+
this.startResolve = undefined;
|
|
216
|
+
this.startReject = undefined;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function workerOptionsFromEnvironment(
|
|
221
|
+
hooks: WorkerHooks,
|
|
222
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
223
|
+
): WorkerClientOptions | undefined {
|
|
224
|
+
const socketPath = environment.PI_TEAM_CONTROL_SOCKET;
|
|
225
|
+
const runtimeId = environment.PI_TEAM_RUNTIME_ID;
|
|
226
|
+
const controlToken = environment.PI_TEAM_CONTROL_TOKEN;
|
|
227
|
+
const ownerProcessNonce = environment.PI_TEAM_OWNER_PROCESS_NONCE;
|
|
228
|
+
if (!socketPath && !runtimeId && !controlToken && !ownerProcessNonce) return undefined;
|
|
229
|
+
if (!socketPath || !runtimeId || !controlToken || !ownerProcessNonce) {
|
|
230
|
+
throw new Error('Incomplete supervised Team worker environment.');
|
|
231
|
+
}
|
|
232
|
+
return { socketPath, runtimeId, controlToken, ownerProcessNonce, hooks };
|
|
233
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Key, matchesKey, truncateToWidth } from '@earendil-works/pi-tui';
|
|
3
|
+
import type { Membership } from '../runtime/membership.ts';
|
|
4
|
+
import type { TeamRuntime } from '../runtime/team-runtime.ts';
|
|
5
|
+
import { ownerProcessNonce } from '../supervisor/supervisor.ts';
|
|
6
|
+
|
|
7
|
+
export type DashboardSnapshot = {
|
|
8
|
+
readonly team: { readonly teamId: string; readonly state: string; readonly alias: string };
|
|
9
|
+
readonly members: readonly {
|
|
10
|
+
readonly id: string; readonly alias: string; readonly kind: string; readonly status: 'online' | 'offline';
|
|
11
|
+
}[];
|
|
12
|
+
readonly inbox: readonly {
|
|
13
|
+
readonly id: string; readonly kind: string; readonly from: string; readonly createdAt: string;
|
|
14
|
+
}[];
|
|
15
|
+
readonly runtimes: readonly {
|
|
16
|
+
readonly id: string; readonly memberId: string; readonly state: string; readonly requestId?: string;
|
|
17
|
+
}[];
|
|
18
|
+
readonly requests: readonly { readonly id: string; readonly status: string; readonly at: string }[];
|
|
19
|
+
readonly leases: readonly {
|
|
20
|
+
readonly id: string; readonly kind: string; readonly resource: string; readonly expiresAt: string;
|
|
21
|
+
}[];
|
|
22
|
+
readonly warnings: readonly string[];
|
|
23
|
+
readonly omitted: boolean;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type Row = { readonly id: string; readonly text: string };
|
|
27
|
+
const DISPLAY_LIMIT = 10;
|
|
28
|
+
|
|
29
|
+
export function sanitizeDashboardText(value: string): string {
|
|
30
|
+
return value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
|
31
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
32
|
+
.replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, ' ')
|
|
33
|
+
.replace(/\s+/g, ' ')
|
|
34
|
+
.trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function loadDashboardSnapshot(
|
|
38
|
+
runtime: TeamRuntime,
|
|
39
|
+
membership: Membership,
|
|
40
|
+
): Promise<DashboardSnapshot> {
|
|
41
|
+
const [team, peers, inbox, runtimes, leases, receipts] = await Promise.all([
|
|
42
|
+
runtime.teams.read(membership.teamId),
|
|
43
|
+
runtime.memberships.peerPage(membership, 100),
|
|
44
|
+
runtime.delivery.inboxItems(membership, 100),
|
|
45
|
+
runtime.runtimes.list(membership.teamId),
|
|
46
|
+
runtime.leases.list(membership.teamId),
|
|
47
|
+
runtime.receipts.list(membership.teamId, membership.memberId, 100),
|
|
48
|
+
]);
|
|
49
|
+
if (!team) throw new Error(`Team "${membership.teamId}" is missing.`);
|
|
50
|
+
const members = [
|
|
51
|
+
{ id: membership.memberId, alias: sanitizeDashboardText(membership.alias), kind: membership.kind, status: 'online' as const },
|
|
52
|
+
...peers.peers.map(peer => ({
|
|
53
|
+
id: peer.memberId,
|
|
54
|
+
alias: sanitizeDashboardText(peer.alias),
|
|
55
|
+
kind: peer.kind,
|
|
56
|
+
status: peer.status,
|
|
57
|
+
})),
|
|
58
|
+
];
|
|
59
|
+
const activeRuntimes = runtimes.filter(record => record.state !== 'terminated' &&
|
|
60
|
+
record.owner.ownerSessionId === membership.sessionId && record.owner.ownerProcessNonce === ownerProcessNonce()).map(record => ({
|
|
61
|
+
id: record.runtimeId,
|
|
62
|
+
memberId: record.memberId,
|
|
63
|
+
state: record.state,
|
|
64
|
+
...(record.activeRequestId ? { requestId: record.activeRequestId } : {}),
|
|
65
|
+
}));
|
|
66
|
+
const activeLeases = leases.filter(lease => !lease.releasedAt && Date.parse(lease.expiresAt) > Date.now()).map(lease => ({
|
|
67
|
+
id: lease.leaseId,
|
|
68
|
+
kind: lease.kind,
|
|
69
|
+
resource: sanitizeDashboardText(lease.resourceId),
|
|
70
|
+
expiresAt: lease.expiresAt,
|
|
71
|
+
}));
|
|
72
|
+
const warnings = [
|
|
73
|
+
...(team.state === 'open' ? [] : [`Team state is ${team.state}.`]),
|
|
74
|
+
...activeRuntimes.filter(record => ['lost', 'stopping'].includes(record.state))
|
|
75
|
+
.map(record => `Runtime ${record.id} is ${record.state}.`),
|
|
76
|
+
];
|
|
77
|
+
return {
|
|
78
|
+
team: { teamId: team.teamId, state: team.state, alias: sanitizeDashboardText(membership.alias) },
|
|
79
|
+
members,
|
|
80
|
+
inbox: inbox.items.map(item => ({
|
|
81
|
+
id: item.messageId,
|
|
82
|
+
kind: item.kind,
|
|
83
|
+
from: item.fromMemberId,
|
|
84
|
+
createdAt: item.createdAt,
|
|
85
|
+
})),
|
|
86
|
+
runtimes: activeRuntimes,
|
|
87
|
+
requests: receipts.map(receipt => ({ id: receipt.messageId, status: receipt.status, at: receipt.at })),
|
|
88
|
+
leases: activeLeases,
|
|
89
|
+
warnings,
|
|
90
|
+
omitted: inbox.nextCursor !== undefined || peers.nextCursor !== undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function dashboardRows(snapshot: DashboardSnapshot): readonly Row[] {
|
|
95
|
+
return [
|
|
96
|
+
{ id: 'section-team', text: `TEAM · ${snapshot.team.teamId} · ${snapshot.team.state} · joined as ${snapshot.team.alias}` },
|
|
97
|
+
{ id: 'section-members', text: `Members (${snapshot.members.length})` },
|
|
98
|
+
...snapshot.members.slice(0, DISPLAY_LIMIT).map(member => ({
|
|
99
|
+
id: `member:${member.id}`,
|
|
100
|
+
text: `${member.status === 'online' ? '● online' : '○ offline'} · ${member.alias} · ${member.kind}`,
|
|
101
|
+
})),
|
|
102
|
+
{ id: 'section-inbox', text: `Inbox (${snapshot.inbox.length}${snapshot.omitted ? '+' : ''})` },
|
|
103
|
+
...snapshot.inbox.slice(0, DISPLAY_LIMIT).map(item => ({
|
|
104
|
+
id: `inbox:${item.id}`,
|
|
105
|
+
text: `◇ ${item.kind} · ${item.id} · from ${item.from} · ${item.createdAt}`,
|
|
106
|
+
})),
|
|
107
|
+
{ id: 'section-runtimes', text: `Owned runtimes (${snapshot.runtimes.length})` },
|
|
108
|
+
...snapshot.runtimes.slice(0, DISPLAY_LIMIT).map(runtime => ({
|
|
109
|
+
id: `runtime:${runtime.id}`,
|
|
110
|
+
text: `◆ ${runtime.state} · ${runtime.id} · member ${runtime.memberId}${runtime.requestId ? ` · request ${runtime.requestId}` : ''}`,
|
|
111
|
+
})),
|
|
112
|
+
{ id: 'section-requests', text: `Requests (${snapshot.requests.length})` },
|
|
113
|
+
...snapshot.requests.slice(0, DISPLAY_LIMIT).map(request => ({
|
|
114
|
+
id: `request:${request.id}`,
|
|
115
|
+
text: `◇ ${request.status} · ${request.id} · ${request.at}`,
|
|
116
|
+
})),
|
|
117
|
+
{ id: 'section-leases', text: `Leases (${snapshot.leases.length})` },
|
|
118
|
+
...snapshot.leases.slice(0, DISPLAY_LIMIT).map(lease => ({
|
|
119
|
+
id: `lease:${lease.id}`,
|
|
120
|
+
text: `◇ ${lease.kind} · ${lease.resource} · expires ${lease.expiresAt}`,
|
|
121
|
+
})),
|
|
122
|
+
{ id: 'section-warnings', text: `Shutdown/recovery warnings (${snapshot.warnings.length})` },
|
|
123
|
+
...snapshot.warnings.slice(0, DISPLAY_LIMIT).map((warning, index) => ({ id: `warning:${index}:${warning}`, text: `! warning · ${warning}` })),
|
|
124
|
+
{ id: 'section-controls', text: '↑↓ select · Esc close view (operations continue)' },
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function renderDashboard(snapshot: DashboardSnapshot, width: number, selectedId?: string): string[] {
|
|
129
|
+
const safeWidth = Math.max(1, width);
|
|
130
|
+
return dashboardRows(snapshot).map(row => {
|
|
131
|
+
const heading = row.id.startsWith('section-');
|
|
132
|
+
const prefix = row.id === selectedId ? '> ' : heading ? ' ' : ' ';
|
|
133
|
+
return truncateToWidth(`${prefix}${row.text}`, safeWidth);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function dashboardText(snapshot: DashboardSnapshot, width = 80): string {
|
|
138
|
+
return renderDashboard(snapshot, width).join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
class DashboardComponent {
|
|
142
|
+
private selectedId?: string;
|
|
143
|
+
|
|
144
|
+
constructor(
|
|
145
|
+
private readonly snapshot: DashboardSnapshot,
|
|
146
|
+
private readonly close: () => void,
|
|
147
|
+
private readonly renderRequested: () => void,
|
|
148
|
+
) {
|
|
149
|
+
this.selectedId = dashboardRows(snapshot).find(row => !row.id.startsWith('section-'))?.id;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private move(offset: number): void {
|
|
153
|
+
const selectable = dashboardRows(this.snapshot).filter(row => !row.id.startsWith('section-'));
|
|
154
|
+
if (selectable.length === 0) return;
|
|
155
|
+
const current = selectable.findIndex(row => row.id === this.selectedId);
|
|
156
|
+
const next = current < 0 ? 0 : Math.max(0, Math.min(selectable.length - 1, current + offset));
|
|
157
|
+
this.selectedId = selectable[next]?.id;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
handleInput(data: string): void {
|
|
161
|
+
if (matchesKey(data, Key.escape)) { this.close(); return; }
|
|
162
|
+
if (matchesKey(data, Key.up)) this.move(-1);
|
|
163
|
+
else if (matchesKey(data, Key.down)) this.move(1);
|
|
164
|
+
else return;
|
|
165
|
+
this.renderRequested();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
render(width: number): string[] { return renderDashboard(this.snapshot, width, this.selectedId); }
|
|
169
|
+
invalidate(): void {}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function openTeamDashboard(context: ExtensionContext, snapshot: DashboardSnapshot): Promise<void> {
|
|
173
|
+
if (!context.hasUI || context.mode !== 'tui') {
|
|
174
|
+
context.ui.notify(dashboardText(snapshot), 'info');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
await context.ui.custom<void>((tui, _theme, _keybindings, done) =>
|
|
178
|
+
new DashboardComponent(snapshot, () => done(undefined), () => tui.requestRender()));
|
|
179
|
+
}
|