@piagent/platform 1.9.3 → 1.11.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 +44 -0
- package/README.md +1 -1
- package/README.vi.md +1 -1
- package/docs/command-reference-vietnamese.md +2 -2
- package/docs/distribution-standard.md +6 -6
- package/docs/en/architecture.md +1 -1
- package/docs/languages.json +4 -4
- package/docs/managed-local-acceptance.md +35 -0
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/team-onboarding.md +2 -2
- package/docs/vi/architecture.md +1 -1
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +22 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +22 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +23 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +23 -1
- package/docs-site/workflows.html +1 -1
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +1 -1
- package/packages/piagent-core/managed/grant-share.mjs +18 -0
- package/packages/piagent-core/managed/helper-roles.mjs +8 -4
- package/packages/piagent-core/managed/helper-run.mjs +116 -0
- package/packages/piagent-core/managed/member-questions.d.mts +15 -0
- package/packages/piagent-core/managed/member-questions.mjs +178 -0
- package/packages/piagent-core/managed/objections.mjs +73 -0
- package/packages/piagent-core/managed/patch-snapshot.mjs +108 -22
- package/packages/piagent-core/managed/request-stream.mjs +54 -7
- package/packages/piagent-core/managed/review-snapshot-worker.mjs +31 -13
- package/packages/piagent-core/managed/session.mjs +33 -85
- package/packages/piagent-core/managed/web-search.mjs +23 -2
- package/packages/piagent-core/managed/workflow.mjs +137 -51
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/runtime/managed-failure.mjs +6 -2
- package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-IaGBsZNX.js → MarkdownMessage-D70xkf0d.js} +1 -1
- package/packages/piagent-webui/dist/client/assets/index-BuIJR5rd.js +228 -0
- package/packages/piagent-webui/dist/client/assets/{index-DgSmcnP8.css → index-Z91z6SlN.css} +1 -1
- package/packages/piagent-webui/dist/client/index.html +2 -2
- package/packages/piagent-webui/gateway/gateway-service.ts +15 -1
- package/packages/piagent-webui/gateway/managed-projection.ts +1 -1
- package/packages/piagent-webui/gateway/session-questions.ts +15 -0
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +7 -2
- package/packages/piagent-webui/gateway/update-center.ts +201 -0
- package/packages/piagent-webui/server/http-security.ts +15 -1
- package/packages/piagent-webui/server/loopback-server.ts +9 -16
- package/packages/piagent-webui/server/member-routes.ts +99 -0
- package/packages/piagent-webui/server/transcript-projection.ts +17 -4
- package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
- package/schemas/piagent-webui/transcript-v1.schema.json +29 -2
- package/scripts/dashboard-update-job.mjs +72 -0
- package/packages/piagent-webui/dist/client/assets/index-Cv5u0MN8.js +0 -228
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import http, { type
|
|
1
|
+
import http, { type ServerResponse } from "node:http";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
|
|
4
|
-
import { errorResponse, applySecurityHeaders, jsonResponse } from "./http-security.ts";
|
|
4
|
+
import { errorResponse, applySecurityHeaders, jsonResponse, requestBody } from "./http-security.ts";
|
|
5
|
+
import { routeMemberRequest, type UpdateRoutes } from "./member-routes.ts";
|
|
5
6
|
import { ReadModelNotFound, type WebUiReadModelProvider } from "./read-model-provider.ts";
|
|
6
7
|
import { routeReadOnlyRequest } from "./read-only-router.ts";
|
|
7
8
|
import { SessionAuthority } from "./session-auth.ts";
|
|
@@ -32,20 +33,6 @@ export type LoopbackServer = {
|
|
|
32
33
|
close(): Promise<void>;
|
|
33
34
|
};
|
|
34
35
|
|
|
35
|
-
function requestBody(request: IncomingMessage, maximumBytes: number): Promise<Buffer> {
|
|
36
|
-
return new Promise((resolve, reject) => {
|
|
37
|
-
const chunks: Buffer[] = []; let bytes = 0, failed = false;
|
|
38
|
-
request.on("data", (chunk: Buffer) => {
|
|
39
|
-
if (failed) return;
|
|
40
|
-
bytes += chunk.length;
|
|
41
|
-
if (bytes > maximumBytes) { failed = true; reject(new Error("body-limit")); }
|
|
42
|
-
else chunks.push(chunk);
|
|
43
|
-
});
|
|
44
|
-
request.on("end", () => { if (!failed) resolve(Buffer.concat(chunks)); });
|
|
45
|
-
request.on("error", (error) => { if (!failed) reject(error); });
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
|
|
49
36
|
function serveAsset(response: ServerResponse, asset: StaticAsset): void {
|
|
50
37
|
response.statusCode = 200;
|
|
51
38
|
response.setHeader("Cache-Control", "no-store");
|
|
@@ -81,6 +68,9 @@ export async function startLoopbackServer(options: {
|
|
|
81
68
|
executeControl?: (command: unknown) => unknown | Promise<unknown>;
|
|
82
69
|
executeAttachment?: (command: unknown) => unknown | Promise<unknown>;
|
|
83
70
|
executeApproval?: (approvalRef: string, decision: unknown) => unknown | Promise<unknown>;
|
|
71
|
+
readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
|
|
72
|
+
answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
|
|
73
|
+
updates?: UpdateRoutes;
|
|
84
74
|
bootstrapTtlMs?: number;
|
|
85
75
|
sessionTtlMs?: number;
|
|
86
76
|
}): Promise<LoopbackServer> {
|
|
@@ -302,6 +292,9 @@ export async function startLoopbackServer(options: {
|
|
|
302
292
|
catch { return errorResponse(response, 503, "attachment-runtime-unavailable"); }
|
|
303
293
|
}
|
|
304
294
|
|
|
295
|
+
// The main agent's questions to the member, and Piagent's own updates.
|
|
296
|
+
if (await routeMemberRequest({ request, response, url, origin, requestOrigin, auth, now, consumeControl }, options)) return;
|
|
297
|
+
|
|
305
298
|
if (request.method === "POST" && url.pathname.startsWith("/api/v1/approvals/") && url.pathname.endsWith("/decision") && options.executeApproval) {
|
|
306
299
|
if (requestOrigin !== origin) return errorResponse(response, 403, "origin-required");
|
|
307
300
|
const mutationSession = auth.authorizeMutation(request);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
|
|
3
|
+
import { errorResponse, jsonResponse, requestBody } from "./http-security.ts";
|
|
4
|
+
import type { SessionAuthority } from "./session-auth.ts";
|
|
5
|
+
|
|
6
|
+
// Requests a member makes beside a conversation: answering the main agent's
|
|
7
|
+
// questions, and keeping Piagent itself up to date. Reads need the browser
|
|
8
|
+
// session; every change also needs this origin, the session's CSRF token and
|
|
9
|
+
// a share of the control rate.
|
|
10
|
+
const MAX_CONTROL_BODY_BYTES = 70_000;
|
|
11
|
+
const CURSOR = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
|
|
12
|
+
const QUESTION_PATH = /^\/api\/v1\/sessions\/([^/]+)\/questions(?:\/([^/]+)\/answer)?$/;
|
|
13
|
+
|
|
14
|
+
export type UpdateRoutes = {
|
|
15
|
+
// What is installed, what is available, and the last update run.
|
|
16
|
+
status(): unknown | Promise<unknown>;
|
|
17
|
+
// Asks the registry now instead of waiting for the next periodic check.
|
|
18
|
+
check(): unknown | Promise<unknown>;
|
|
19
|
+
// Starts the update to the version the member saw; refused when it moved.
|
|
20
|
+
apply(request: unknown): unknown | Promise<unknown>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type Context = {
|
|
24
|
+
request: IncomingMessage; response: ServerResponse; url: URL; origin: string; requestOrigin: string | undefined;
|
|
25
|
+
auth: SessionAuthority; now: number; consumeControl(sessionId: string, now: number): boolean;
|
|
26
|
+
};
|
|
27
|
+
type Options = {
|
|
28
|
+
readSessionQuestions?: (sessionRef: string) => unknown | Promise<unknown>;
|
|
29
|
+
answerSessionQuestion?: (sessionRef: string, questionRef: string, answer: unknown) => unknown | Promise<unknown>;
|
|
30
|
+
updates?: UpdateRoutes;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// The checks every change shares; a refusal is answered here and false returned.
|
|
34
|
+
function authorizeChange(context: Context): boolean {
|
|
35
|
+
const { request, response, origin, requestOrigin, auth, now, consumeControl } = context;
|
|
36
|
+
if (requestOrigin !== origin) { errorResponse(response, 403, "origin-required"); return false; }
|
|
37
|
+
const session = auth.authorizeMutation(request);
|
|
38
|
+
if (!session) { errorResponse(response, 403, "mutation-authority-rejected"); return false; }
|
|
39
|
+
if (!consumeControl(session.id, now)) { errorResponse(response, 429, "control-rate-limit"); return false; }
|
|
40
|
+
if (!String(request.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) { errorResponse(response, 415, "content-type"); return false; }
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function jsonBody(context: Context, code: string): Promise<{ ok: true; value: unknown } | { ok: false }> {
|
|
45
|
+
try { return { ok: true, value: JSON.parse((await requestBody(context.request, MAX_CONTROL_BODY_BYTES)).toString("utf8")) }; }
|
|
46
|
+
catch (error) { errorResponse(context.response, (error as Error).message === "body-limit" ? 413 : 400, code); return { ok: false }; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const UPDATE_STATUS: Record<string, number> = {
|
|
50
|
+
"update-not-installable": 409, "update-blocked-running": 409, "update-already-running": 409,
|
|
51
|
+
"update-version-changed": 409, "update-not-available": 409, "update-request-invalid": 400
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// True when the request was one of these routes (answered, or refused).
|
|
55
|
+
export async function routeMemberRequest(context: Context, options: Options): Promise<boolean> {
|
|
56
|
+
const { request, response, url, auth } = context;
|
|
57
|
+
const question = QUESTION_PATH.exec(url.pathname);
|
|
58
|
+
if (request.method === "GET" && question && !question[2] && options.readSessionQuestions) {
|
|
59
|
+
if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
|
|
60
|
+
const sessionRef = decodeURIComponent(question[1]);
|
|
61
|
+
if (!CURSOR.test(sessionRef)) { errorResponse(response, 400, "invalid-session-ref"); return true; }
|
|
62
|
+
try { jsonResponse(response, 200, await options.readSessionQuestions(sessionRef)); }
|
|
63
|
+
catch { errorResponse(response, 503, "questions-unavailable"); }
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (request.method === "POST" && question && question[2] && options.answerSessionQuestion) {
|
|
67
|
+
if (!authorizeChange(context)) return true;
|
|
68
|
+
const sessionRef = decodeURIComponent(question[1]), questionRef = decodeURIComponent(question[2]);
|
|
69
|
+
if (!CURSOR.test(sessionRef) || !/^question\.[0-9a-f-]{36}$/.test(questionRef)) { errorResponse(response, 400, "invalid-question-ref"); return true; }
|
|
70
|
+
const body = await jsonBody(context, "invalid-question-answer");
|
|
71
|
+
if (!body.ok) return true;
|
|
72
|
+
try { jsonResponse(response, 200, await options.answerSessionQuestion(sessionRef, questionRef, body.value)); }
|
|
73
|
+
catch (error) {
|
|
74
|
+
const code = error instanceof Error ? error.message : "questions-unavailable";
|
|
75
|
+
errorResponse(response, code === "question-not-pending" ? 409 : code === "question-answer-invalid" ? 400 : 503, code);
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!options.updates || !url.pathname.startsWith("/api/v1/updates")) return false;
|
|
81
|
+
const updates = options.updates;
|
|
82
|
+
if (request.method === "GET" && url.pathname === "/api/v1/updates") {
|
|
83
|
+
if (!auth.authenticate(request)) { errorResponse(response, 401, "authentication-required"); return true; }
|
|
84
|
+
try { jsonResponse(response, 200, await updates.status()); }
|
|
85
|
+
catch { errorResponse(response, 503, "update-status-unavailable"); }
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
const action = request.method === "POST" ? /^\/api\/v1\/updates\/(check|apply)$/.exec(url.pathname)?.[1] : undefined;
|
|
89
|
+
if (!action) { errorResponse(response, 404, "not-found"); return true; }
|
|
90
|
+
if (!authorizeChange(context)) return true;
|
|
91
|
+
const body = await jsonBody(context, "update-request-invalid");
|
|
92
|
+
if (!body.ok) return true;
|
|
93
|
+
try { jsonResponse(response, action === "apply" ? 202 : 200, action === "apply" ? await updates.apply(body.value) : await updates.check()); }
|
|
94
|
+
catch (error) {
|
|
95
|
+
const code = error instanceof Error ? error.message : "update-unavailable";
|
|
96
|
+
errorResponse(response, UPDATE_STATUS[code] ?? 503, UPDATE_STATUS[code] ? code : "update-unavailable");
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
@@ -35,8 +35,10 @@ type TranscriptItem = { messageRef: string; parentMessageRef: string | null; rol
|
|
|
35
35
|
toolCalls: Array<{ toolCallRef: string; toolName: string; state: "requested" | "completed" | "failed" | "unknown";
|
|
36
36
|
summary?: ToolSummary; change?: ToolChange; result?: ToolResult }>; usage?: TurnUsage; model?: string; process?: HarnessProcess };
|
|
37
37
|
type HarnessFinding = { severity: "blocking" | "major" | "minor"; file: string; line: number | null; issue: string };
|
|
38
|
-
type
|
|
39
|
-
|
|
38
|
+
type HarnessIssue = { kind: "wrong_premise" | "conflicts_with_request" | "ambiguous" | "out_of_scope"; detail: string };
|
|
39
|
+
type HarnessProcess = { phase: "plan" | "verify" | "review" | "final" | "objection" | "answer" | "claims" | "rejudge" | "dispute"; outcome?: string; loop?: number; maxLoops?: number;
|
|
40
|
+
verified?: boolean; reviewed?: boolean; blockingOpen?: number; planOpen?: number; planSkipped?: true; verifyPolicy?: string; reviewPolicy?: string; reviewUnavailable?: string;
|
|
41
|
+
findings?: HarnessFinding[]; role?: "scout" | "research" | "verify" | "review"; by?: "main" | "harness"; disputes?: number; unchanged?: true; issues?: HarnessIssue[]; claims?: { claim: string }[] };
|
|
40
42
|
export type TranscriptDocument = { schemaVersion: 1; version: "piagent-webui-transcript-v1"; generatedAt: string; identity: TranscriptIdentity;
|
|
41
43
|
revision: TranscriptRevision; eventCursor: string; state: "ready" | "unavailable"; items: TranscriptItem[];
|
|
42
44
|
page: { beforeCursor: string | null; nextBeforeCursor: string | null; hasOlder: boolean; limit: number; truncated: boolean }; reasonCode: string | null };
|
|
@@ -163,10 +165,13 @@ function toolCalls(message: any, sessionRef: string, role: TranscriptItem["role"
|
|
|
163
165
|
}
|
|
164
166
|
// The harness's process notes ("agent-watch-process"): what it asked of the
|
|
165
167
|
// agent before a code-changing turn ended, and how that turn ended.
|
|
166
|
-
const PROCESS_OUTCOMES = ["no_change", "interrupted", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"];
|
|
168
|
+
const PROCESS_OUTCOMES = ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"];
|
|
169
|
+
// A helper's objection to its brief, a failed claim, a disagreement handed to the member.
|
|
170
|
+
const PROCESS_PHASES = ["plan", "verify", "review", "final", "objection", "answer", "claims", "rejudge", "dispute"];
|
|
171
|
+
const ISSUE_KINDS = ["wrong_premise", "conflicts_with_request", "ambiguous", "out_of_scope"], HELPER_ROLES = ["scout", "research", "verify", "review"];
|
|
167
172
|
const POLICY_MODES = ["off", "suggest", "require"];
|
|
168
173
|
function harnessProcess(details: any): HarnessProcess | null {
|
|
169
|
-
if (!
|
|
174
|
+
if (!PROCESS_PHASES.includes(details?.phase)) return null;
|
|
170
175
|
const loop = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 0 && (n as number) <= 3;
|
|
171
176
|
const out: HarnessProcess = { phase: details.phase };
|
|
172
177
|
if (PROCESS_OUTCOMES.includes(details.outcome)) out.outcome = details.outcome;
|
|
@@ -184,6 +189,14 @@ function harnessProcess(details: any): HarnessProcess | null {
|
|
|
184
189
|
.filter((f: any) => ["blocking", "major", "minor"].includes(f?.severity) && typeof f.issue === "string" && f.issue.trim()).slice(0, 10)
|
|
185
190
|
.map((f: any) => ({ severity: f.severity, file: safeText(String(f.file ?? "")).full.slice(0, 300), line: Number.isSafeInteger(f.line) && f.line > 0 ? f.line : null,
|
|
186
191
|
issue: safeText(f.issue).full.slice(0, 500) }));
|
|
192
|
+
if (HELPER_ROLES.includes(details.role)) out.role = details.role;
|
|
193
|
+
if (["main", "harness"].includes(details.by)) out.by = details.by;
|
|
194
|
+
if (Number.isInteger(details.disputes) && details.disputes >= 1) out.disputes = Math.min(details.disputes, 1000);
|
|
195
|
+
if (details.unchanged === true) out.unchanged = true;
|
|
196
|
+
if (Array.isArray(details.issues)) out.issues = details.issues.filter((i: any) => ISSUE_KINDS.includes(i?.kind) && typeof i.detail === "string" && i.detail.trim()).slice(0, 10)
|
|
197
|
+
.map((i: any) => ({ kind: i.kind, detail: safeText(i.detail).full.slice(0, 400) }));
|
|
198
|
+
if (Array.isArray(details.claims)) out.claims = details.claims.filter((c: any) => typeof c?.claim === "string" && c.claim.trim()).slice(0, 10)
|
|
199
|
+
.map((c: any) => ({ claim: safeText(c.claim).full.slice(0, 500) }));
|
|
187
200
|
return out;
|
|
188
201
|
}
|
|
189
202
|
function role(message: any): TranscriptItem["role"] | null {
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"steps": { "type": "array", "minItems": 1, "maxItems": 30, "items": { "type": "object", "additionalProperties": false, "required": ["step", "status"], "properties": {
|
|
98
98
|
"step": { "type": "string", "minLength": 1, "maxLength": 200 }, "status": { "enum": ["pending", "in_progress", "completed"] } } } } } },
|
|
99
99
|
"managedProcess": { "type": "object", "additionalProperties": false, "required": ["outcome", "verified", "reviewed", "blockingOpen"], "properties": {
|
|
100
|
-
"outcome": { "enum": ["no_change", "interrupted", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
|
|
100
|
+
"outcome": { "enum": ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
|
|
101
101
|
"verified": { "type": "boolean" }, "reviewed": { "type": "boolean" }, "blockingOpen": { "type": "integer", "minimum": 0, "maximum": 1000 }, "planSkipped": { "const": true } } },
|
|
102
102
|
"modelLabel": { "$ref": "common-v1.schema.json#/$defs/nullableDisplayText" },
|
|
103
103
|
"thinkingLevel": { "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "max", "unknown"] },
|
|
@@ -157,8 +157,35 @@
|
|
|
157
157
|
"additionalProperties": false,
|
|
158
158
|
"required": ["phase"],
|
|
159
159
|
"properties": {
|
|
160
|
-
"phase": { "enum": ["plan", "verify", "review", "final"] },
|
|
161
|
-
"outcome": { "enum": ["no_change", "interrupted", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
|
|
160
|
+
"phase": { "enum": ["plan", "verify", "review", "final", "objection", "answer", "claims", "rejudge", "dispute"] },
|
|
161
|
+
"outcome": { "enum": ["no_change", "interrupted", "disputed", "blocking_open", "unverified", "review_unavailable", "unreviewed", "clean"] },
|
|
162
|
+
"role": { "enum": ["scout", "research", "verify", "review"] },
|
|
163
|
+
"by": { "enum": ["main", "harness"] },
|
|
164
|
+
"disputes": { "type": "integer", "minimum": 1, "maximum": 1000 },
|
|
165
|
+
"unchanged": { "const": true },
|
|
166
|
+
"issues": {
|
|
167
|
+
"type": "array",
|
|
168
|
+
"maxItems": 10,
|
|
169
|
+
"items": {
|
|
170
|
+
"type": "object",
|
|
171
|
+
"additionalProperties": false,
|
|
172
|
+
"required": ["kind", "detail"],
|
|
173
|
+
"properties": {
|
|
174
|
+
"kind": { "enum": ["wrong_premise", "conflicts_with_request", "ambiguous", "out_of_scope"] },
|
|
175
|
+
"detail": { "type": "string", "maxLength": 400 }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
"claims": {
|
|
180
|
+
"type": "array",
|
|
181
|
+
"maxItems": 10,
|
|
182
|
+
"items": {
|
|
183
|
+
"type": "object",
|
|
184
|
+
"additionalProperties": false,
|
|
185
|
+
"required": ["claim"],
|
|
186
|
+
"properties": { "claim": { "type": "string", "maxLength": 500 } }
|
|
187
|
+
}
|
|
188
|
+
},
|
|
162
189
|
"loop": { "type": "integer", "minimum": 0, "maximum": 3 },
|
|
163
190
|
"maxLoops": { "type": "integer", "minimum": 0, "maximum": 3 },
|
|
164
191
|
"verified": { "type": "boolean" },
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One update started from the dashboard, run apart from it: the dashboard is
|
|
3
|
+
// restarted at the end, so this process must outlive it. It moves Pi and
|
|
4
|
+
// Piagent to the chosen release with `piagent-update` (Pi first, to the
|
|
5
|
+
// version that release pins), restarts the dashboard on the new code, and
|
|
6
|
+
// leaves the outcome in ~/.pi/piagent-update-job.json for the new dashboard to
|
|
7
|
+
// show. The full output goes to ~/.pi/piagent-update-job.log.
|
|
8
|
+
//
|
|
9
|
+
// dashboard-update-job.mjs --from 1.10.0 --to 1.11.0
|
|
10
|
+
// [--updater <script>] [--dashboard <script>] [--home <dir>] (tests)
|
|
11
|
+
import crypto from "node:crypto";
|
|
12
|
+
import fs from "node:fs";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
const RELEASE = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
|
|
19
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
|
+
|
|
21
|
+
function option(name, fallback) {
|
|
22
|
+
const index = process.argv.indexOf(`--${name}`);
|
|
23
|
+
return index > 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
|
|
24
|
+
}
|
|
25
|
+
const from = option("from"), to = option("to");
|
|
26
|
+
const home = path.resolve(option("home", os.homedir()));
|
|
27
|
+
const updater = option("updater", path.join(packageRoot, "scripts", "update-global.mjs"));
|
|
28
|
+
const dashboard = option("dashboard", path.join(packageRoot, "scripts", "piagent-dashboard.mjs"));
|
|
29
|
+
if (!RELEASE.test(from ?? "") || !RELEASE.test(to ?? "")) {
|
|
30
|
+
process.stderr.write("usage: dashboard-update-job.mjs --from X.Y.Z --to X.Y.Z\n");
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const directory = path.join(home, ".pi"), jobFile = path.join(directory, "piagent-update-job.json");
|
|
35
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
36
|
+
const startedAt = (() => {
|
|
37
|
+
try { const value = JSON.parse(fs.readFileSync(jobFile, "utf8")); return value?.to === to && Number.isFinite(Date.parse(value.startedAt)) ? value.startedAt : undefined; }
|
|
38
|
+
catch { return undefined; }
|
|
39
|
+
})() ?? new Date().toISOString();
|
|
40
|
+
function record(fields) {
|
|
41
|
+
const temporary = `${jobFile}.${process.pid}.tmp`;
|
|
42
|
+
fs.writeFileSync(temporary, `${JSON.stringify({ from, to, startedAt, pid: process.pid, ...fields }, null, 2)}\n`, { mode: 0o600 });
|
|
43
|
+
fs.renameSync(temporary, jobFile);
|
|
44
|
+
}
|
|
45
|
+
const finish = (state, fields = {}) => { record({ state, finishedAt: new Date().toISOString(), ...fields }); process.exit(state === "succeeded" ? 0 : 1); };
|
|
46
|
+
|
|
47
|
+
record({ state: "running" });
|
|
48
|
+
const log = fs.openSync(path.join(directory, "piagent-update-job.log"), "w", 0o600);
|
|
49
|
+
const env = { ...process.env, PIAGENT_NO_UPDATE_CHECK: "1" };
|
|
50
|
+
fs.writeSync(log, `Piagent update ${from} -> ${to} (${new Date().toISOString()})\n`);
|
|
51
|
+
const update = spawnSync(process.execPath, [updater, "--version", to], { stdio: ["ignore", log, log], env, timeout: 30 * 60 * 1000 });
|
|
52
|
+
if (update.status !== 0) finish("failed", { reason: "update-command-failed" });
|
|
53
|
+
|
|
54
|
+
let installed;
|
|
55
|
+
try { installed = JSON.parse(fs.readFileSync(path.join(packageRoot, "package.json"), "utf8")).version; } catch { installed = undefined; }
|
|
56
|
+
if (installed !== to) finish("failed", { reason: "update-version-mismatch", ...(RELEASE.test(installed ?? "") ? { installed } : {}) });
|
|
57
|
+
|
|
58
|
+
// Agent Watch pins the company launcher it starts; a release that changed
|
|
59
|
+
// the launcher needs the binding imported again in Agent Watch.
|
|
60
|
+
let bindingChanged = false;
|
|
61
|
+
try {
|
|
62
|
+
const agentDir = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(home, ".pi", "agent");
|
|
63
|
+
const binding = JSON.parse(fs.readFileSync(path.join(agentDir, "agent-watch-managed.json"), "utf8"));
|
|
64
|
+
if (typeof binding?.entrypoint === "string" && typeof binding?.entrypoint_sha256 === "string")
|
|
65
|
+
bindingChanged = crypto.createHash("sha256").update(fs.readFileSync(binding.entrypoint)).digest("hex") !== binding.entrypoint_sha256;
|
|
66
|
+
} catch { /* no company binding on this machine */ }
|
|
67
|
+
|
|
68
|
+
// The new dashboard opens in a new browser tab: the old tab's browser session
|
|
69
|
+
// ends with the old dashboard. Its launch link is never written to the log.
|
|
70
|
+
fs.writeSync(log, "\nRestarting the dashboard on the new release\n");
|
|
71
|
+
const restart = spawnSync(process.execPath, [dashboard, "restart", "--json"], { stdio: ["ignore", "ignore", log], env, timeout: 3 * 60 * 1000 });
|
|
72
|
+
finish("succeeded", { installed, ...(bindingChanged ? { bindingChanged } : {}), ...(restart.status === 0 ? {} : { reason: "dashboard-restart-failed" }) });
|