@tt-a1i/openpi 0.4.0 → 0.5.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 +102 -40
- package/SETUP.md +22 -6
- package/assets/openpi-launch-card-v1.webp +0 -0
- package/bin/openpi.js +145 -0
- package/extensions/background-terminals/index.ts +30 -2
- package/extensions/background-terminals/src/domain.ts +2 -0
- package/extensions/background-terminals/src/manager.ts +486 -106
- package/extensions/background-terminals/src/output.ts +33 -0
- package/extensions/background-terminals/src/prompt.ts +13 -5
- package/extensions/background-terminals/src/result-delivery.ts +4 -1
- package/extensions/clear-context/index.ts +83 -0
- package/extensions/context-pivot/index.ts +16 -6
- package/extensions/cron/schedule.ts +7 -1
- package/extensions/file-mutation-display/render.ts +17 -257
- package/extensions/file-search/src/binaries.ts +57 -41
- package/extensions/git-read/index.ts +1 -3
- package/extensions/model-info/index.ts +21 -33
- package/extensions/model-info/session-metrics.ts +96 -0
- package/extensions/plan-mode/bash-policy.ts +54 -9
- package/extensions/plan-mode/index.ts +7 -2
- package/extensions/post-edit/index.ts +16 -6
- package/extensions/sessions/git-stats.ts +258 -72
- package/extensions/sessions/index.ts +153 -86
- package/extensions/sessions/preview-cache.ts +104 -0
- package/extensions/sessions/preview-loader.ts +856 -0
- package/extensions/sessions/sessions.ts +43 -4
- package/extensions/setup/index.ts +123 -127
- package/extensions/shared/activity-status.ts +30 -0
- package/extensions/shared/agent-session-page.ts +319 -0
- package/extensions/shared/agent-tool-renderer.ts +218 -0
- package/extensions/shared/agent-transcript.ts +524 -0
- package/extensions/shared/capability-intent.ts +1 -1
- package/extensions/shared/child-session.ts +437 -21
- package/extensions/shared/result-delivery.ts +34 -0
- package/extensions/shared/setup-config.ts +73 -33
- package/extensions/shared/setup-episode-state.ts +1 -1
- package/extensions/shared/terminal-text.ts +110 -23
- package/extensions/shared/text-projection.ts +72 -15
- package/extensions/shared/tool-activity.ts +382 -0
- package/extensions/shared/tool-surface.ts +29 -2
- package/extensions/shared/transcript-viewport.ts +46 -0
- package/extensions/shared/web-observer-registry.ts +390 -0
- package/extensions/shared/worktree.ts +11 -0
- package/extensions/subagents/index.ts +270 -59
- package/extensions/subagents/navigation.ts +34 -5
- package/extensions/subagents/src/backend.ts +12 -1
- package/extensions/subagents/src/backends/pi.ts +375 -66
- package/extensions/subagents/src/domain.ts +5 -0
- package/extensions/subagents/src/manager.ts +34 -2
- package/extensions/subagents/src/prompt.ts +32 -4
- package/extensions/subagents/src/result-artifact.ts +4 -0
- package/extensions/subagents/src/result-delivery.ts +7 -1
- package/extensions/subagents/src/runtime.ts +15 -1
- package/extensions/subagents/src/ui/takeover.ts +73 -257
- package/extensions/subagents/src/ui/transcript.ts +38 -535
- package/extensions/subagents/src/ui/wait-result.ts +103 -15
- package/extensions/suggestions/src/ui.ts +10 -4
- package/extensions/tasks/index.ts +0 -3
- package/extensions/ui-customization/footer.ts +0 -40
- package/extensions/ui-customization/index.ts +0 -4
- package/extensions/user-input-fold/index.ts +1 -1
- package/extensions/web/index.ts +234 -0
- package/extensions/workflows/artifacts.ts +137 -47
- package/extensions/workflows/completion-projection.ts +457 -0
- package/extensions/workflows/coordinator.ts +8 -10
- package/extensions/workflows/dashboard.ts +167 -228
- package/extensions/workflows/handoff.ts +70 -16
- package/extensions/workflows/index.ts +488 -198
- package/extensions/workflows/journal.ts +148 -13
- package/extensions/workflows/model.ts +74 -4
- package/extensions/workflows/navigation.ts +32 -8
- package/extensions/workflows/progress-projection.ts +306 -0
- package/extensions/workflows/prompt.ts +66 -6
- package/extensions/workflows/replay-safety.ts +42 -21
- package/extensions/workflows/result-delivery.ts +128 -64
- package/extensions/workflows/retention.ts +593 -0
- package/extensions/workflows/runner.ts +388 -279
- package/extensions/workflows/sandbox-child.cjs +25 -3
- package/extensions/workflows/sandbox.ts +62 -8
- package/extensions/workflows/serialization.ts +325 -17
- package/extensions/workflows/tool-renderer.ts +22 -0
- package/extensions/workflows/transcript.ts +149 -0
- package/extensions/workspace-cleanup-guard/index.ts +54 -0
- package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
- package/package.json +28 -8
- package/skills/subagents/REFERENCE.md +189 -0
- package/skills/subagents/SKILL.md +1 -1
- package/skills/workflows/REFERENCE.md +4 -2
- package/web/adapter/pi-adapter.ts +661 -0
- package/web/host/browser-launcher.ts +20 -0
- package/web/host/static-assets.ts +4 -0
- package/web/host/terminal-status.ts +38 -0
- package/web/host/web-host.ts +789 -0
- package/web/http-dispatcher.ts +125 -0
- package/web/protocol/types.ts +462 -0
- package/web/runtime/pi-runtime.ts +991 -0
- package/web/runtime/types.ts +71 -0
- package/web/runtime/web-host-lease.ts +497 -0
- package/web/trace.ts +18 -0
- package/web/ui/app.js +1398 -0
- package/web/ui/index.html +139 -0
- package/web/ui/styles.css +598 -0
- package/web/vite.config.mjs +34 -0
- package/extensions/execution-convergence/active-evidence.ts +0 -129
- package/extensions/execution-convergence/index.ts +0 -442
- package/extensions/execution-convergence/workspace-provenance.ts +0 -338
- package/extensions/setup/intercom-fs-helper.cjs +0 -130
- package/extensions/setup/intercom.ts +0 -603
- package/extensions/subagents/src/backends/stub.ts +0 -303
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { WebModelSummary } from "../protocol/types.ts";
|
|
3
|
+
|
|
4
|
+
export interface WebRuntimeEvent {
|
|
5
|
+
type: string;
|
|
6
|
+
detail?: Record<string, unknown>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type WebRuntimeRequestErrorCode =
|
|
10
|
+
| "MODEL_NOT_AVAILABLE"
|
|
11
|
+
| "SESSION_CONFLICT"
|
|
12
|
+
| "PROMPT_REJECTED"
|
|
13
|
+
| "WORKSPACE_REQUIRED";
|
|
14
|
+
|
|
15
|
+
export class WebRuntimeRequestError extends Error {
|
|
16
|
+
readonly code: WebRuntimeRequestErrorCode;
|
|
17
|
+
readonly statusCode: 400 | 409 | 422;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
message: string,
|
|
21
|
+
code: WebRuntimeRequestErrorCode,
|
|
22
|
+
statusCode: 400 | 409 | 422,
|
|
23
|
+
) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "WebRuntimeRequestError";
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.statusCode = statusCode;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface WebPromptOptions {
|
|
32
|
+
commandId?: string;
|
|
33
|
+
expectedSessionId?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WebModelSelectionOptions {
|
|
37
|
+
expectedSessionId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface WebSessionCreationOptions {
|
|
41
|
+
commandId?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface WebSessionCreationResult {
|
|
45
|
+
cancelled: boolean;
|
|
46
|
+
commandId?: string;
|
|
47
|
+
sessionPath?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WebRuntimeController {
|
|
51
|
+
readonly cwd: string;
|
|
52
|
+
/** Runtime authority: false until a real Web workspace and Session are active. */
|
|
53
|
+
readonly workspaceSelected: boolean;
|
|
54
|
+
readonly sessionDirectory: string;
|
|
55
|
+
readonly sessionManager: SessionManager;
|
|
56
|
+
isIdle(): boolean;
|
|
57
|
+
sendPrompt(content: string, options?: WebPromptOptions): Promise<void>;
|
|
58
|
+
newSession(
|
|
59
|
+
workspacePath: string,
|
|
60
|
+
options?: WebSessionCreationOptions,
|
|
61
|
+
): Promise<WebSessionCreationResult>;
|
|
62
|
+
switchSession(sessionPath: string): Promise<{ cancelled: boolean }>;
|
|
63
|
+
listModels(): WebModelSummary[];
|
|
64
|
+
setModel(
|
|
65
|
+
provider: string,
|
|
66
|
+
modelId: string,
|
|
67
|
+
options?: WebModelSelectionOptions,
|
|
68
|
+
): Promise<WebModelSummary>;
|
|
69
|
+
subscribe(listener: (event: WebRuntimeEvent) => void): () => void;
|
|
70
|
+
dispose(): Promise<void>;
|
|
71
|
+
}
|
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import {
|
|
5
|
+
lstat,
|
|
6
|
+
mkdir,
|
|
7
|
+
open,
|
|
8
|
+
opendir,
|
|
9
|
+
readFile,
|
|
10
|
+
rename,
|
|
11
|
+
rmdir,
|
|
12
|
+
unlink,
|
|
13
|
+
} from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { promisify } from "node:util";
|
|
16
|
+
|
|
17
|
+
export const WEB_HOST_LEASE_DIRECTORY = ".openpi-web-host.lock";
|
|
18
|
+
export const WEB_HOST_LEASE_OWNER_FILE = "owner.json";
|
|
19
|
+
export const WEB_HOST_LEASE_ARTIFACT_DIRECTORY =
|
|
20
|
+
".openpi-web-host.artifacts";
|
|
21
|
+
export const WEB_HOST_MAX_STALE_TOMBSTONES = 64;
|
|
22
|
+
export const WEB_HOST_MAX_LEASE_ARTIFACTS = 128;
|
|
23
|
+
|
|
24
|
+
const NONCE_PATTERN =
|
|
25
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
26
|
+
|
|
27
|
+
type OwnerIdentity = {
|
|
28
|
+
version: 1;
|
|
29
|
+
pid: number;
|
|
30
|
+
nonce: string;
|
|
31
|
+
processStartedAt: string;
|
|
32
|
+
createdAt: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const execFileAsync = promisify(execFile);
|
|
36
|
+
let currentProcessIdentity: Promise<string> | undefined;
|
|
37
|
+
|
|
38
|
+
function errorCode(error: unknown) {
|
|
39
|
+
return error instanceof Error && "code" in error
|
|
40
|
+
? String(error.code)
|
|
41
|
+
: undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function invalidMetadata(path: string, cause?: unknown): never {
|
|
45
|
+
throw new Error(`Web Host ownership metadata is invalid at ${path}`, {
|
|
46
|
+
cause,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function validProcessIdentity(value: string) {
|
|
51
|
+
if (/^linux:[0-9a-f-]{36}:\d+$/i.test(value)) {
|
|
52
|
+
return NONCE_PATTERN.test(value.slice(6, 42));
|
|
53
|
+
}
|
|
54
|
+
if (/^win32:\d+$/.test(value)) return true;
|
|
55
|
+
const prefix = `${process.platform}:boot=`;
|
|
56
|
+
if (!value.startsWith(prefix)) return false;
|
|
57
|
+
const separator = value.indexOf(";start=", prefix.length);
|
|
58
|
+
if (separator < 0) return false;
|
|
59
|
+
const bootedAt = value.slice(prefix.length, separator);
|
|
60
|
+
const startedAt = value.slice(separator + 7);
|
|
61
|
+
return (
|
|
62
|
+
bootedAt.length > 0 &&
|
|
63
|
+
bootedAt.length <= 128 &&
|
|
64
|
+
startedAt.length > 0 &&
|
|
65
|
+
startedAt.length <= 128 &&
|
|
66
|
+
!/[\r\n;]/.test(bootedAt) &&
|
|
67
|
+
!/[\r\n;]/.test(startedAt)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseOwner(text: string, path: string) {
|
|
72
|
+
let value: unknown;
|
|
73
|
+
try {
|
|
74
|
+
value = JSON.parse(text);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
invalidMetadata(path, error);
|
|
77
|
+
}
|
|
78
|
+
if (
|
|
79
|
+
typeof value !== "object" ||
|
|
80
|
+
value === null ||
|
|
81
|
+
Array.isArray(value) ||
|
|
82
|
+
!("version" in value) ||
|
|
83
|
+
value.version !== 1 ||
|
|
84
|
+
!("pid" in value) ||
|
|
85
|
+
!Number.isSafeInteger(value.pid) ||
|
|
86
|
+
(value.pid as number) <= 0 ||
|
|
87
|
+
!("nonce" in value) ||
|
|
88
|
+
typeof value.nonce !== "string" ||
|
|
89
|
+
!NONCE_PATTERN.test(value.nonce) ||
|
|
90
|
+
!("processStartedAt" in value) ||
|
|
91
|
+
typeof value.processStartedAt !== "string" ||
|
|
92
|
+
!validProcessIdentity(value.processStartedAt) ||
|
|
93
|
+
!("createdAt" in value) ||
|
|
94
|
+
!Number.isSafeInteger(value.createdAt) ||
|
|
95
|
+
(value.createdAt as number) <= 0
|
|
96
|
+
) {
|
|
97
|
+
invalidMetadata(path);
|
|
98
|
+
}
|
|
99
|
+
return value as OwnerIdentity;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function sameOwner(left: OwnerIdentity, right: OwnerIdentity) {
|
|
103
|
+
return (
|
|
104
|
+
left.pid === right.pid &&
|
|
105
|
+
left.nonce === right.nonce &&
|
|
106
|
+
left.processStartedAt === right.processStartedAt
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function validateDirectory(path: string, privateDirectory: boolean) {
|
|
111
|
+
let info;
|
|
112
|
+
try {
|
|
113
|
+
info = await lstat(path);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new Error(`Cannot inspect Web Host lease path at ${path}`, {
|
|
116
|
+
cause: error,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
120
|
+
throw new Error(`Web Host lease path is not a private directory: ${path}`);
|
|
121
|
+
}
|
|
122
|
+
if (
|
|
123
|
+
privateDirectory &&
|
|
124
|
+
process.platform !== "win32" &&
|
|
125
|
+
(info.mode & 0o077) !== 0
|
|
126
|
+
) {
|
|
127
|
+
throw new Error(`Web Host lease directory permissions are unsafe: ${path}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function readOwner(path: string) {
|
|
132
|
+
let before;
|
|
133
|
+
try {
|
|
134
|
+
before = await lstat(path);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
invalidMetadata(path, error);
|
|
137
|
+
}
|
|
138
|
+
if (before.isSymbolicLink() || !before.isFile()) invalidMetadata(path);
|
|
139
|
+
if (process.platform !== "win32" && (before.mode & 0o077) !== 0) {
|
|
140
|
+
throw new Error(`Web Host ownership metadata permissions are unsafe: ${path}`);
|
|
141
|
+
}
|
|
142
|
+
let handle;
|
|
143
|
+
try {
|
|
144
|
+
handle = await open(path, constants.O_RDONLY);
|
|
145
|
+
const opened = await handle.stat();
|
|
146
|
+
if (
|
|
147
|
+
!opened.isFile() ||
|
|
148
|
+
opened.dev !== before.dev ||
|
|
149
|
+
opened.ino !== before.ino
|
|
150
|
+
) {
|
|
151
|
+
invalidMetadata(path);
|
|
152
|
+
}
|
|
153
|
+
return parseOwner(await handle.readFile("utf8"), path);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
invalidMetadata(path, error);
|
|
156
|
+
} finally {
|
|
157
|
+
await handle?.close();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function writeOwner(path: string, owner: OwnerIdentity) {
|
|
162
|
+
let handle;
|
|
163
|
+
try {
|
|
164
|
+
handle = await open(
|
|
165
|
+
path,
|
|
166
|
+
constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY,
|
|
167
|
+
0o600,
|
|
168
|
+
);
|
|
169
|
+
await handle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
|
|
170
|
+
await handle.sync();
|
|
171
|
+
} finally {
|
|
172
|
+
await handle?.close();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function processExists(pid: number) {
|
|
177
|
+
try {
|
|
178
|
+
process.kill(pid, 0);
|
|
179
|
+
return true;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (errorCode(error) === "ESRCH") return false;
|
|
182
|
+
throw new Error(`Cannot safely determine whether Web Host PID ${pid} is alive`, {
|
|
183
|
+
cause: error,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function readProcessIdentity(pid: number) {
|
|
189
|
+
if (process.platform === "linux") {
|
|
190
|
+
const [stat, bootIdText] = await Promise.all([
|
|
191
|
+
readFile(`/proc/${pid}/stat`, "utf8"),
|
|
192
|
+
readFile("/proc/sys/kernel/random/boot_id", "utf8"),
|
|
193
|
+
]);
|
|
194
|
+
const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" ");
|
|
195
|
+
const startTicks = fields[19];
|
|
196
|
+
const bootId = bootIdText.trim();
|
|
197
|
+
if (!startTicks || !/^\d+$/.test(startTicks)) {
|
|
198
|
+
throw new Error(`Cannot parse Linux start identity for Web Host PID ${pid}`);
|
|
199
|
+
}
|
|
200
|
+
if (!NONCE_PATTERN.test(bootId)) {
|
|
201
|
+
throw new Error("Cannot parse Linux boot identity for Web Host ownership");
|
|
202
|
+
}
|
|
203
|
+
return `linux:${bootId}:${startTicks}`;
|
|
204
|
+
}
|
|
205
|
+
if (process.platform === "win32") {
|
|
206
|
+
const script = `(Get-Process -Id ${pid} -ErrorAction Stop).StartTime.ToUniversalTime().Ticks`;
|
|
207
|
+
const { stdout } = await execFileAsync(
|
|
208
|
+
"powershell.exe",
|
|
209
|
+
["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script],
|
|
210
|
+
{ windowsHide: true },
|
|
211
|
+
);
|
|
212
|
+
const ticks = stdout.trim();
|
|
213
|
+
if (!/^\d+$/.test(ticks)) {
|
|
214
|
+
throw new Error(`Cannot parse Windows start identity for Web Host PID ${pid}`);
|
|
215
|
+
}
|
|
216
|
+
return `win32:${ticks}`;
|
|
217
|
+
}
|
|
218
|
+
const environment = { ...process.env, LC_ALL: "C" };
|
|
219
|
+
const [{ stdout: bootOutput }, { stdout: startOutput }] = await Promise.all([
|
|
220
|
+
execFileAsync("/usr/sbin/sysctl", ["-n", "kern.boottime"], {
|
|
221
|
+
env: environment,
|
|
222
|
+
}),
|
|
223
|
+
execFileAsync("/bin/ps", ["-p", String(pid), "-o", "lstart="], {
|
|
224
|
+
env: environment,
|
|
225
|
+
}),
|
|
226
|
+
]);
|
|
227
|
+
const bootedAt = bootOutput.trim().replace(/\s+/g, " ");
|
|
228
|
+
const startedAt = startOutput.trim().replace(/\s+/g, " ");
|
|
229
|
+
if (!bootedAt || bootedAt.length > 256) {
|
|
230
|
+
throw new Error("Cannot parse POSIX boot identity for Web Host ownership");
|
|
231
|
+
}
|
|
232
|
+
if (!startedAt) {
|
|
233
|
+
throw new Error(`Cannot read process start identity for Web Host PID ${pid}`);
|
|
234
|
+
}
|
|
235
|
+
return `${process.platform}:boot=${bootedAt};start=${startedAt}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function ownerIsAlive(owner: OwnerIdentity) {
|
|
239
|
+
if (!processExists(owner.pid)) return false;
|
|
240
|
+
try {
|
|
241
|
+
const identity = owner.pid === process.pid
|
|
242
|
+
? await getCurrentProcessIdentity()
|
|
243
|
+
: await readProcessIdentity(owner.pid);
|
|
244
|
+
return identity === owner.processStartedAt;
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (!processExists(owner.pid)) return false;
|
|
247
|
+
throw new Error(
|
|
248
|
+
`Cannot safely verify the start identity of Web Host PID ${owner.pid}`,
|
|
249
|
+
{ cause: error },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function getCurrentProcessIdentity() {
|
|
255
|
+
currentProcessIdentity ??= readProcessIdentity(process.pid);
|
|
256
|
+
return currentProcessIdentity;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function pathExists(path: string) {
|
|
260
|
+
try {
|
|
261
|
+
await lstat(path);
|
|
262
|
+
return true;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
if (errorCode(error) === "ENOENT") return false;
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function artifactKind(name: string) {
|
|
270
|
+
for (const kind of ["stale", "candidate", "released"] as const) {
|
|
271
|
+
const prefix = `${kind}-`;
|
|
272
|
+
if (name.startsWith(prefix) && NONCE_PATTERN.test(name.slice(prefix.length))) {
|
|
273
|
+
return kind;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function inspectLeaseArtifacts(artifactDirectory: string) {
|
|
280
|
+
let stale = 0;
|
|
281
|
+
let total = 0;
|
|
282
|
+
const directory = await opendir(artifactDirectory);
|
|
283
|
+
for await (const entry of directory) {
|
|
284
|
+
const kind = artifactKind(entry.name);
|
|
285
|
+
total += 1;
|
|
286
|
+
if (kind === "stale") stale += 1;
|
|
287
|
+
if (total >= WEB_HOST_MAX_LEASE_ARTIFACTS) break;
|
|
288
|
+
}
|
|
289
|
+
return { stale, total };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function prepareArtifactDirectory(sessionDirectory: string) {
|
|
293
|
+
const artifactDirectory = join(
|
|
294
|
+
sessionDirectory,
|
|
295
|
+
WEB_HOST_LEASE_ARTIFACT_DIRECTORY,
|
|
296
|
+
);
|
|
297
|
+
try {
|
|
298
|
+
await mkdir(artifactDirectory, { mode: 0o700 });
|
|
299
|
+
} catch (error) {
|
|
300
|
+
if (errorCode(error) !== "EEXIST") throw error;
|
|
301
|
+
}
|
|
302
|
+
await validateDirectory(artifactDirectory, true);
|
|
303
|
+
return artifactDirectory;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function assertArtifactCapacity(total: number) {
|
|
307
|
+
if (total < WEB_HOST_MAX_LEASE_ARTIFACTS) return;
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Web Host lease acquisition is blocked because ${WEB_HOST_MAX_LEASE_ARTIFACTS} package-owned safety artifacts are retained; verify ownership and remove obsolete candidate-*, released-*, or stale-* entries from ${WEB_HOST_LEASE_ARTIFACT_DIRECTORY} manually`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function assertStaleCapacity(stale: number) {
|
|
314
|
+
if (stale < WEB_HOST_MAX_STALE_TOMBSTONES) return;
|
|
315
|
+
throw new Error(
|
|
316
|
+
`Web Host stale recovery is blocked because ${WEB_HOST_MAX_STALE_TOMBSTONES} safety tombstones are retained; verify no Web Host depends on them and remove obsolete stale-* entries from ${WEB_HOST_LEASE_ARTIFACT_DIRECTORY} manually`,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function unlinkOwned(path: string, expected: OwnerIdentity) {
|
|
321
|
+
const current = await readOwner(path);
|
|
322
|
+
if (!sameOwner(current, expected)) {
|
|
323
|
+
throw new Error(`Web Host ownership changed before release: ${path}`);
|
|
324
|
+
}
|
|
325
|
+
await unlink(path);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function cleanupOwnedDirectory(
|
|
329
|
+
directory: string,
|
|
330
|
+
expected: OwnerIdentity,
|
|
331
|
+
) {
|
|
332
|
+
const ownerPath = join(directory, WEB_HOST_LEASE_OWNER_FILE);
|
|
333
|
+
const current = await readOwner(ownerPath);
|
|
334
|
+
if (!sameOwner(current, expected)) {
|
|
335
|
+
throw new Error(`Web Host ownership changed before release: ${ownerPath}`);
|
|
336
|
+
}
|
|
337
|
+
await unlinkOwned(ownerPath, expected);
|
|
338
|
+
await rmdir(directory);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function moveOwnedDirectory(
|
|
342
|
+
source: string,
|
|
343
|
+
destination: string,
|
|
344
|
+
expected: OwnerIdentity,
|
|
345
|
+
) {
|
|
346
|
+
const current = await readOwner(join(source, WEB_HOST_LEASE_OWNER_FILE));
|
|
347
|
+
if (!sameOwner(current, expected)) {
|
|
348
|
+
throw new Error(`Web Host ownership changed before release: ${source}`);
|
|
349
|
+
}
|
|
350
|
+
await rename(source, destination);
|
|
351
|
+
const moved = await readOwner(join(destination, WEB_HOST_LEASE_OWNER_FILE));
|
|
352
|
+
if (!sameOwner(moved, expected)) {
|
|
353
|
+
throw new Error(`Web Host ownership changed while moving ${source}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function tryPublish(candidate: string, lockDirectory: string) {
|
|
358
|
+
if (await pathExists(lockDirectory)) return false;
|
|
359
|
+
try {
|
|
360
|
+
await rename(candidate, lockDirectory);
|
|
361
|
+
return true;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (await pathExists(lockDirectory)) return false;
|
|
364
|
+
throw error;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function cleanupCandidate(candidate: string, owner: OwnerIdentity) {
|
|
369
|
+
if (!(await pathExists(candidate))) return;
|
|
370
|
+
try {
|
|
371
|
+
await cleanupOwnedDirectory(candidate, owner);
|
|
372
|
+
} catch {
|
|
373
|
+
// Candidate directories are never authoritative. A nonce-scoped orphan is
|
|
374
|
+
// inert and cannot block canonical publication.
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
class DirectoryWebHostLease {
|
|
379
|
+
private readonly artifactDirectory: string;
|
|
380
|
+
private cleanupDirectory: string | undefined;
|
|
381
|
+
private released = false;
|
|
382
|
+
private readonly lockDirectory: string;
|
|
383
|
+
private readonly owner: OwnerIdentity;
|
|
384
|
+
|
|
385
|
+
constructor(
|
|
386
|
+
lockDirectory: string,
|
|
387
|
+
artifactDirectory: string,
|
|
388
|
+
owner: OwnerIdentity,
|
|
389
|
+
) {
|
|
390
|
+
this.lockDirectory = lockDirectory;
|
|
391
|
+
this.artifactDirectory = artifactDirectory;
|
|
392
|
+
this.owner = owner;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async release() {
|
|
396
|
+
if (!this.released) {
|
|
397
|
+
const releaseDirectory = join(
|
|
398
|
+
this.artifactDirectory,
|
|
399
|
+
`released-${this.owner.nonce}`,
|
|
400
|
+
);
|
|
401
|
+
await moveOwnedDirectory(
|
|
402
|
+
this.lockDirectory,
|
|
403
|
+
releaseDirectory,
|
|
404
|
+
this.owner,
|
|
405
|
+
);
|
|
406
|
+
this.released = true;
|
|
407
|
+
this.cleanupDirectory = releaseDirectory;
|
|
408
|
+
}
|
|
409
|
+
if (!this.cleanupDirectory) return;
|
|
410
|
+
await cleanupOwnedDirectory(this.cleanupDirectory, this.owner);
|
|
411
|
+
this.cleanupDirectory = undefined;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function recoverStaleOwner(
|
|
416
|
+
artifactDirectory: string,
|
|
417
|
+
lockDirectory: string,
|
|
418
|
+
candidate: string,
|
|
419
|
+
observed: OwnerIdentity,
|
|
420
|
+
owner: OwnerIdentity,
|
|
421
|
+
) {
|
|
422
|
+
const ownerPath = join(lockDirectory, WEB_HOST_LEASE_OWNER_FILE);
|
|
423
|
+
const current = await readOwner(ownerPath);
|
|
424
|
+
if (!sameOwner(current, observed)) {
|
|
425
|
+
throw new Error("Web Host ownership changed during stale recovery");
|
|
426
|
+
}
|
|
427
|
+
if (await ownerIsAlive(current)) {
|
|
428
|
+
throw new Error(`Web Host runtime is already owned by live PID ${current.pid}`);
|
|
429
|
+
}
|
|
430
|
+
const artifacts = await inspectLeaseArtifacts(artifactDirectory);
|
|
431
|
+
assertArtifactCapacity(artifacts.total);
|
|
432
|
+
assertStaleCapacity(artifacts.stale);
|
|
433
|
+
const staleDirectory = join(
|
|
434
|
+
artifactDirectory,
|
|
435
|
+
`stale-${observed.nonce}`,
|
|
436
|
+
);
|
|
437
|
+
if (await pathExists(staleDirectory)) {
|
|
438
|
+
throw new Error("Web Host lease recovery lost a concurrent ownership race");
|
|
439
|
+
}
|
|
440
|
+
await moveOwnedDirectory(lockDirectory, staleDirectory, observed);
|
|
441
|
+
if (!(await tryPublish(candidate, lockDirectory))) {
|
|
442
|
+
throw new Error("Web Host lease recovery lost a concurrent ownership race");
|
|
443
|
+
}
|
|
444
|
+
return new DirectoryWebHostLease(lockDirectory, artifactDirectory, owner);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export async function acquireWebHostLease(sessionDirectory: string) {
|
|
448
|
+
await mkdir(sessionDirectory, { recursive: true, mode: 0o700 });
|
|
449
|
+
await validateDirectory(sessionDirectory, false);
|
|
450
|
+
const artifactDirectory = await prepareArtifactDirectory(sessionDirectory);
|
|
451
|
+
const artifacts = await inspectLeaseArtifacts(artifactDirectory);
|
|
452
|
+
assertArtifactCapacity(artifacts.total);
|
|
453
|
+
const lockDirectory = join(sessionDirectory, WEB_HOST_LEASE_DIRECTORY);
|
|
454
|
+
const owner: OwnerIdentity = {
|
|
455
|
+
version: 1,
|
|
456
|
+
pid: process.pid,
|
|
457
|
+
nonce: randomUUID(),
|
|
458
|
+
processStartedAt: await getCurrentProcessIdentity(),
|
|
459
|
+
createdAt: Date.now(),
|
|
460
|
+
};
|
|
461
|
+
const candidate = join(
|
|
462
|
+
artifactDirectory,
|
|
463
|
+
`candidate-${owner.nonce}`,
|
|
464
|
+
);
|
|
465
|
+
await mkdir(candidate, { mode: 0o700 });
|
|
466
|
+
await writeOwner(join(candidate, WEB_HOST_LEASE_OWNER_FILE), owner);
|
|
467
|
+
try {
|
|
468
|
+
if (!(await pathExists(lockDirectory))) {
|
|
469
|
+
if (await tryPublish(candidate, lockDirectory)) {
|
|
470
|
+
return new DirectoryWebHostLease(
|
|
471
|
+
lockDirectory,
|
|
472
|
+
artifactDirectory,
|
|
473
|
+
owner,
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
await validateDirectory(lockDirectory, true);
|
|
478
|
+
const ownerPath = join(lockDirectory, WEB_HOST_LEASE_OWNER_FILE);
|
|
479
|
+
const observed = await readOwner(ownerPath);
|
|
480
|
+
if (await ownerIsAlive(observed)) {
|
|
481
|
+
throw new Error(
|
|
482
|
+
`Web Host runtime is already owned by live PID ${observed.pid}`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return await recoverStaleOwner(
|
|
486
|
+
artifactDirectory,
|
|
487
|
+
lockDirectory,
|
|
488
|
+
candidate,
|
|
489
|
+
observed,
|
|
490
|
+
owner,
|
|
491
|
+
);
|
|
492
|
+
} finally {
|
|
493
|
+
await cleanupCandidate(candidate, owner);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export type WebHostLease = Awaited<ReturnType<typeof acquireWebHostLease>>;
|
package/web/trace.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const traceEnabled =
|
|
2
|
+
process.env.OPENPI_WEB_DEBUG === "1" || process.env.OPENPI_WEB_LOG === "1";
|
|
3
|
+
|
|
4
|
+
export function traceWeb(event: string, detail: Record<string, unknown> = {}) {
|
|
5
|
+
if (!traceEnabled) return;
|
|
6
|
+
process.stderr.write(
|
|
7
|
+
`${JSON.stringify({
|
|
8
|
+
scope: "openpi-web",
|
|
9
|
+
timestamp: new Date().toISOString(),
|
|
10
|
+
event,
|
|
11
|
+
...detail,
|
|
12
|
+
})}\n`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function elapsed(start: number) {
|
|
17
|
+
return Math.round((performance.now() - start) * 100) / 100;
|
|
18
|
+
}
|