Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions admin/src/console/actions.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,8 @@ const configActions = [
fileAction({
name: "secret-profile-add",
kind: "secret-profile-add",
build: (body, ctx) => ctx.a.plans.planSecretProfileAdd({ name: str(body, "name", { required: true, max: 64 }), path: str(body, "path", { required: true, max: 4096 }), profiles: profilesOf(ctx), settingsFile: ctx.a.paths.settingsFile }),
// Issue #712: the env-declared names and the resolver roots judged before the confirm, as `/dispatch secrets add` does.
build: (body, ctx) => ctx.a.plans.planSecretProfileAdd({ name: str(body, "name", { required: true, max: 64 }), path: str(body, "path", { required: true, max: 4096 }), profiles: profilesOf(ctx), envProfiles: ctx.a.rm.envSecretProfiles?.(ctx.a.env?.PI_SECRET_PROFILES)?.profiles ?? {}, env: ctx.a.env ?? null, settingsFile: ctx.a.paths.settingsFile, ...(ctx.fs ? { fs: ctx.fs } : {}) }),
target: (ctx) => ctx.a.paths.settingsFile,
write: writeSettingsOf,
}),
Expand All @@ -695,7 +696,7 @@ const configActions = [
fileAction({
name: "secret-bundle-add",
kind: "secret-bundle-add",
build: (body, ctx) => ctx.a.plans.planSecretBundleAdd({ name: str(body, "name", { required: true, max: 64 }), path: str(body, "path", { required: true, max: 4096 }), format: str(body, "format", { required: true, max: 16 }), bundles: bundleProfilesOf(ctx), envBundles: ctx.a.rm.envBundleProfiles(ctx.a.env?.PI_SECRET_BUNDLES).profiles, settingsFile: ctx.a.paths.settingsFile }),
build: (body, ctx) => ctx.a.plans.planSecretBundleAdd({ name: str(body, "name", { required: true, max: 64 }), path: str(body, "path", { required: true, max: 4096 }), format: str(body, "format", { required: true, max: 16 }), bundles: bundleProfilesOf(ctx), envBundles: ctx.a.rm.envBundleProfiles(ctx.a.env?.PI_SECRET_BUNDLES).profiles, env: ctx.a.env ?? null, settingsFile: ctx.a.paths.settingsFile, ...(ctx.fs ? { fs: ctx.fs } : {}) }),
target: (ctx) => ctx.a.paths.settingsFile,
write: writeSettingsOf,
}),
Expand Down
53 changes: 45 additions & 8 deletions admin/src/console/panels.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ import {
import { fmtCost } from "../panel.mjs";
import { renderWhatIf } from "../render.mjs";
import { recordedProgress } from "@edgehero/pi-dispatch/run-history";
import { BUNDLE_SKIPPED_TEXT, bundleCountsText, bundleDeclaredText, bundleEndpointsText, bundleReceivedText, checkpointsText, forgeLinkOf, isProgressSnapshot, overlayBundleProfiles, progressStaleFor, recordBundle, recordRecovery, recoveredText, secretProfileNames, triggerBundleOf } from "../read-model.mjs";
import { UNATTRIBUTED_BACKEND } from "@edgehero/pi-dispatch/backends";
import { BUNDLE_SKIPPED_TEXT, LIVE_SKIPPED_TEXT, STEER_SKIPPED_TEXT, VENUE_MAY_NOT_STREAM, liveAbsentCause, progressAbsentText, recordSkips, sandboxShellRefusal, bundleCountsText, bundleDeclaredText, bundleEndpointsText, bundleReceivedText, checkpointsText, forgeLinkOf, isProgressSnapshot, overlayBundleProfiles, progressStaleFor, recordBundle, recordRecovery, recoveredText, secretProfileNames, triggerBundleOf } from "../read-model.mjs";
import { RUN_FILTER_FIELDS } from "../run-filter.mjs";
import { sessionKeyOfRecord } from "@edgehero/pi-dispatch/session-key";
import { parkedVerdictOf } from "@edgehero/pi-dispatch/chat-mode";
Expand Down Expand Up @@ -518,8 +519,12 @@ function liveProgressBody(g, view) {
])}<h3>Running now</h3>${running}<h3>Phase</h3>${phaseBody(g, operator)}<h3>Recent steps</h3>${table("Recent steps", ["Tool", "Status", { num: "Took" }, "At"], recent.map((r) => html`<tr${r.status === "ok" ? null : html` class="warn"`}><td class="mono">${r.tool}</td><td>${r.status}</td><td class="num">${stepDuration(r.durationMs)}</td><td>${utcText(r.at)}</td></tr>`), "No step has finished yet.")}`;
}

/** Why a run has no progress snapshot: the key cannot tell these apart, so the panel names all three. */
export const PROGRESS_ABSENT = "No live progress: not published (PI_RUN_PROGRESS=0), expired, or no step has been recorded yet.";
/**
* Why a run on a venue that streams has no progress snapshot: the key cannot tell these apart, so the panel names all
* three. Issue #712: the panel says `progressAbsentText(record)`, `TRANSCRIPT_ABSENT`'s rule (progress is read off the
* live transcript), so a run on a venue that does not stream it is not blamed on the switch alone.
*/
export const PROGRESS_ABSENT = progressAbsentText({ backend: "local" });

/**
* The run page's "Progress" panel (issue #644). A run with a record shows the record's `progress` (and, while the key
Expand Down Expand Up @@ -566,13 +571,18 @@ export function runProgress(view) {
const recordStart = typeof record.startedAt === "string" ? Date.parse(record.startedAt) : Number.NaN;
const same = live && live.ended && Number.isFinite(recordEnd) && Number.isFinite(recordStart) && live.startedAt >= recordStart && live.startedAt <= recordEnd;
const names = same && view.role === "operator" && live.phases.length > 0 ? html`<h3>Phases</h3>${phaseBody(live, true)}` : null;
if (body === null && names === null) return titled(title, html`<p class="note">This run's record holds no progress (an older worker, or PI_RUN_PROGRESS=0).</p>`);
// Issue #712: a record that names its live skip says it; one on a venue that does not always stream adds that cause.
if (body === null && names === null) {
const cause = liveAbsentCause(record);
const why = cause?.skipped ? `it had no live transcript to read it from: ${cause.skipped}` : cause?.venue ? `an older worker, PI_RUN_PROGRESS=0, ${VENUE_MAY_NOT_STREAM}` : "an older worker, or PI_RUN_PROGRESS=0";
return titled(title, html`<p class="note">This run's record holds no progress (${why}).</p>`);
}
return titled(title, html`${body ?? html`<p class="note">This run's record holds no progress.</p>`}${names}`);
}
if (live) return titled(title, liveProgressBody(live, view));
if (g?.unreachable) return titled(title, html`<p class="warn">The run's progress could not be read (is Valkey reachable?).</p>`);
if (g?.invalid) return titled(title, html`<p class="warn">The run's progress key is not one this console reads (a newer or older worker?).</p>`);
return titled(title, html`<p class="note">${PROGRESS_ABSENT}</p>`);
return titled(title, html`<p class="note">${progressAbsentText(record)}</p>`);
}

// ---- run inbox (issue #646) ------------------------------------------------------------------------------------------
Expand Down Expand Up @@ -749,7 +759,26 @@ function chatDrawsSteer(view) {
*/
function runActions(r, view) {
if (view.role !== "operator" || typeof r.jobId !== "string" || !RUN_ID_RE.test(r.jobId)) return null;
return html`<h3>Actions</h3><p>Open its retained workspace on the host: <code class="mono">${`pi-dispatch sandbox ${r.jobId}`}</code></p>${canAct(view) ? actForm("rerun", { hidden: [["jobId", r.jobId]], label: "Preview a re-run" }) : null}`;
// Issue #712: the command only for a venue with a sandbox shell (`serves.sandboxShell`, held to the worker's
// SANDBOX_LAUNCHERS), and otherwise the reason the panel's sandbox pane gives, in its words (`sandboxShellRefusal`). A
// record with no `backend` is a `local` run from before attribution (`UNATTRIBUTED_BACKEND`), as the sandbox reads it.
const venue = typeof r.backend === "string" && r.backend !== "" ? r.backend : UNATTRIBUTED_BACKEND;
const refused = sandboxShellRefusal(venue);
const reopen = refused === null ? html`<p>Open its retained workspace on the host: <code class="mono">${`pi-dispatch sandbox ${r.jobId}`}</code></p>` : html`<p class="note">Its workspace is ${refused}.</p>`;
return html`<h3>Actions</h3>${reopen}${canAct(view) ? actForm("rerun", { hidden: [["jobId", r.jobId]], label: "Preview a re-run" }) : null}`;
}

/**
* The run summary's skip rows (issue #712): "Steering" (why a steer-armed run took no message, the record's
* `steerSkipped`) and "Live transcript" (why it had none, `liveSkipped`), each the closed enum and its sentence; no row
* for a record that carries neither, so every other run renders byte-identical.
*/
function skipRows(r) {
const { liveSkipped, steerSkipped } = recordSkips(r);
return [
["Steering", steerSkipped !== null ? `skipped (${steerSkipped}): ${STEER_SKIPPED_TEXT[steerSkipped]}` : null],
["Live transcript", liveSkipped !== null ? `none (${liveSkipped}): ${LIVE_SKIPPED_TEXT[liveSkipped]}` : null],
];
}

/**
Expand Down Expand Up @@ -803,6 +832,7 @@ export const runSummary = withRun("Run", (r, view, source) => {
...recoveryRows(r),
["Host", cell(r.host)],
["Venue", cell(r.backend)],
...skipRows(r),
["Project", cell(r.project)],
...bundleRows(r),
["Provider and model", [r.provider, r.model].filter(Boolean).join(" / ") || "-"],
Expand Down Expand Up @@ -1373,8 +1403,15 @@ function profilesEditor(view) {

export const configSecretProfiles = fromSnapshot("Secret profiles", (s, view) => {
const names = secretProfileNames(s.settings);
if (names.length === 0) return html`<p class="note">No secret profile is declared.</p>${profilesEditor(view)}`;
return html`<p class="note">Names only: a profile's resolver and its references stay in the settings file.</p><ul class="plain">${names.map((n) => html`<li class="mono">${n}</li>`)}</ul>${profilesEditor(view)}`;
// Issue #712: the PI_SECRET_PROFILES names too (the snapshot's `secretProfilesEnv`), since the worker merges both; a
// name in both is the worker's ambiguity, which refuses every secrets job, and is marked so.
const env = s.secretProfilesEnv ?? null;
const fromEnv = Array.isArray(env?.names) ? env.names : [];
const envNote = env?.malformed ? html`<p class="warn">PI_SECRET_PROFILES does not parse, so the worker refuses to boot until it is fixed.</p>` : null;
// "None" only where the console can see both sources; otherwise it names the one it read.
if (names.length === 0 && fromEnv.length === 0) return html`<p class="note">No secret profile is declared${env?.seen === true ? "" : " in the settings overlay"}.</p>${envNote}${profilesEditor(view)}`;
const overlayItem = (n) => (fromEnv.includes(n) ? html`<li class="mono warn">${n} (AMBIGUOUS: also in PI_SECRET_PROFILES, so the worker refuses every secrets job)</li>` : html`<li class="mono">${n}</li>`);
return html`<p class="note">Names only: a profile's resolver and its references stay in the settings file${fromEnv.length > 0 ? " or the .env" : ""}.</p><ul class="plain">${fromEnv.map((n) => html`<li class="mono">${n} (PI_SECRET_PROFILES)</li>`)}${names.map(overlayItem)}</ul>${envNote}${profilesEditor(view)}`;
});

/** The bundle resolvers editor (issue #704): a name, its resolver's absolute path and the format it prints. */
Expand Down
13 changes: 9 additions & 4 deletions admin/src/console/transcript.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* Nothing here reaches `/api/state`, a hub panel or a list.
*/
import { LIVE_OUTCOMES, LIVE_TOOL_RE } from "@edgehero/pi-dispatch/live-transcript";
import { TRANSCRIPT_ID_RE } from "../read-model.mjs";
import { TRANSCRIPT_ID_RE, transcriptAbsentText } from "../read-model.mjs";
import { html } from "./html.mjs";

/** The kinds that carry text worth folding: a tool call's arguments and a tool's result go in a `<details>`. */
Expand Down Expand Up @@ -101,8 +101,13 @@ export function transcriptEntryJson(entry) {
};
}

/** Why a run has no stream: the stream cannot tell these apart, so the page names all three. */
export const TRANSCRIPT_ABSENT = "No live transcript: not published (PI_LIVE_TRANSCRIPTS=0), expired, or not started yet.";
/**
* Why a run on a venue that streams it has no stream: the stream cannot tell these apart, so the page names all three.
* Issue #712: the page says `transcriptAbsentText(record)`, which is this sentence for such a venue, the record's named
* skip when it has one (`liveSkipped`), and these three plus the venue's cause for a run on a venue that does not always
* stream or whose venue the page cannot name yet (a running job has no record).
*/
export const TRANSCRIPT_ABSENT = transcriptAbsentText({ backend: "local" });

/**
* The run page's "Live transcript" section (operator only), from the page data's `transcript` (the fleet reader's
Expand All @@ -118,7 +123,7 @@ export function runTranscriptSection(view) {
if (typeof id !== "string" || id === "") return title;
const meta = view?.data?.transcript ?? null;
if (meta?.unreachable) return html`${title}<p class="warn">The live transcript could not be read (is Valkey reachable?). Reload to try again.</p>`;
if (meta?.exists !== true) return html`${title}<p class="note">${TRANSCRIPT_ABSENT} Reload to check again.</p>`;
if (meta?.exists !== true) return html`${title}<p class="note">${transcriptAbsentText(view?.data?.run?.record ?? null)} Reload to check again.</p>`;
const note = html`<p class="note">${meta.ended ? `The run has ended (${meta.outcome ?? "unknown"}); its stream is kept 24 hours.` : "The run is publishing its transcript."}</p>`;
return html`${title}${note}<p class="t-status" data-transcript-status role="status" aria-live="polite">not live</p><div class="transcript" data-transcript data-job="${id}" tabindex="0"></div>`;
}
Loading
Loading