diff --git a/config/agentrun.yaml b/config/agentrun.yaml index b0e75775..2bca0475 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -301,7 +301,7 @@ controlPlane: modelProviders: OpenAI: name: OpenAI - baseUrl: https://api.pikapython.com/ + baseUrl: https://api.pikapython.com/v1 wireApi: responses requiresOpenaiAuth: true features: diff --git a/scripts/src/agentrun/entry.ts b/scripts/src/agentrun/entry.ts index cc567005..0b7d8ba8 100644 --- a/scripts/src/agentrun/entry.ts +++ b/scripts/src/agentrun/entry.ts @@ -38,6 +38,7 @@ import { controlPlaneApply, controlPlanePlan, parseCleanupReleasedPvOptions, par import { gitMirrorStatus } from "./git-mirror"; import { agentRunExplain, isRecord, parseGitMirrorStatusOptions, parseStatusOptions, parseTriggerOptions } from "./options"; import { renderAgentRunControlPlaneActionSummary, renderAgentRunControlPlanePlanSummary, renderAgentRunControlPlaneStatusSummary } from "./public-exposure"; +import { providerProfileHelp, runProviderProfileCommand } from "./provider-profile"; import { renderedCliResult } from "./render"; import { agentRunGetKindHelp, runAgentRunResourceCommand } from "./resource-actions"; import { runAgentRunRestCompatCommand, runGitMirrorJob, startAsyncAgentRunJob } from "./rest-bridge"; @@ -94,6 +95,10 @@ export function agentRunHelp(): unknown { "bun scripts/cli.ts agentrun control-plane cleanup-released-pvs --limit 200 --confirm", "bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node NC01 --lane nc01-v02 --dry-run", "bun scripts/cli.ts agentrun control-plane cleanup-local-postgres --node NC01 --lane nc01-v02 --confirm", + "bun scripts/cli.ts agentrun provider-profile plan --node NC01 --lane nc01-v02 --profile gpt.pika", + "bun scripts/cli.ts agentrun provider-profile apply --node NC01 --lane nc01-v02 --profile gpt.pika --confirm", + "bun scripts/cli.ts agentrun provider-profile status --node NC01 --lane nc01-v02 --profile gpt.pika", + "bun scripts/cli.ts agentrun provider-profile validate --node NC01 --lane nc01-v02 --profile gpt.pika --wait", "bun scripts/cli.ts agentrun git-mirror status", "bun scripts/cli.ts agentrun git-mirror status --full", "bun scripts/cli.ts agentrun git-mirror sync --confirm", @@ -156,6 +161,7 @@ export async function runAgentRunCommand(config: UniDeskConfig | null, args: str if (action === "cleanup-local-postgres") return await cleanupLocalPostgres(config, parseLaneConfirmOptions(actionArgs)); if (action === "cleanup-released-pvs") return await cleanupReleasedPvs(config, parseCleanupReleasedPvOptions(actionArgs)); } + if (group === "provider-profile") return await runProviderProfileCommand(config, action, actionArgs); if (group === "git-mirror") { if (action === "status") return await gitMirrorStatus(config, parseGitMirrorStatusOptions(actionArgs)); if (action === "sync" || action === "flush") { @@ -299,6 +305,7 @@ export function agentRunHelpText(args: string[]): string { " bun scripts/cli.ts agentrun control-plane cleanup-runs --min-age-minutes 30 --limit 200 --dry-run", ].join("\n"); } + if (verb === "provider-profile") return providerProfileHelp(); if (verb === "git-mirror") { return [ "Usage: bun scripts/cli.ts agentrun git-mirror [--full|--raw|--confirm]", diff --git a/scripts/src/agentrun/provider-profile.ts b/scripts/src/agentrun/provider-profile.ts new file mode 100644 index 00000000..973baea4 --- /dev/null +++ b/scripts/src/agentrun/provider-profile.ts @@ -0,0 +1,438 @@ +// SPEC: issue-1616 YAML-first AgentRun provider profile online configuration. +// Provides a thin CLI over YAML-declared provider credential Secrets, runtime GitOps env and bounded validation. +import type { UniDeskConfig } from "../config"; +import { + agentRunImageArtifact, + renderedFilesDigest, + renderAgentRunGitopsFiles, +} from "../agentrun-manifests"; +import { + agentRunLaneSummary, + agentRunProviderCredentialRefs, + resolveAgentRunLaneTarget, + type AgentRunLaneSecretSpec, + type AgentRunLaneSpec, +} from "../agentrun-lanes"; +import { refreshYamlLaneScript } from "./git-mirror"; +import type { ConfirmOptions, DisclosureOptions } from "./options"; +import { compactAgentRunLaneStatusTarget } from "./trigger"; +import { + collectLaneSecretSources, + createYamlLaneJobScript, + readSecretSourceValue, + restartYamlLaneScript, + secretSyncScript, + yamlLaneGitopsPublishJobManifest, + yamlLaneGitopsPublishPayloadFromProbe, + yamlLaneJobProbeScript, +} from "./secrets"; +import { capture, captureJsonPayload, compactCapture, progressEvent, record, shQuote, sleep, stringOrNull } from "./utils"; + +export interface ProviderProfileOptions extends ConfirmOptions, DisclosureOptions { + readonly node: string | null; + readonly lane: string | null; + readonly profile: string | null; + readonly wait: boolean; + readonly prompt: string | null; +} + +export function parseProviderProfileOptions(args: string[]): ProviderProfileOptions { + let node: string | null = null; + let lane: string | null = null; + let profile: string | null = null; + let full = false; + let raw = false; + let wait = false; + let prompt: string | null = null; + let confirm = false; + let dryRun = false; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--confirm") { + confirm = true; + continue; + } + if (arg === "--dry-run") { + dryRun = true; + continue; + } + if (arg === "--full") { + full = true; + continue; + } + if (arg === "--raw") { + raw = true; + full = true; + continue; + } + if (arg === "--wait") { + wait = true; + continue; + } + if (arg === "--node" || arg === "--lane" || arg === "--profile" || arg === "--prompt") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`); + if (arg === "--node") node = value; + if (arg === "--lane") lane = value; + if (arg === "--profile") profile = value; + if (arg === "--prompt") prompt = value; + index += 1; + continue; + } + throw new Error(`unsupported provider-profile option: ${arg}`); + } + if (confirm && dryRun) throw new Error("accepts only one of --confirm or --dry-run"); + return { node, lane, profile, full, raw, wait, prompt, confirm, dryRun: dryRun || !confirm }; +} + +export function providerProfileHelp(): string { + return [ + "Usage: bun scripts/cli.ts agentrun provider-profile --node --lane --profile [options]", + "", + "Examples:", + " bun scripts/cli.ts agentrun provider-profile plan --node NC01 --lane nc01-v02 --profile gpt.pika", + " bun scripts/cli.ts agentrun provider-profile status --node NC01 --lane nc01-v02 --profile gpt.pika", + " bun scripts/cli.ts agentrun provider-profile apply --node NC01 --lane nc01-v02 --profile gpt.pika --confirm", + " bun scripts/cli.ts agentrun provider-profile validate --node NC01 --lane nc01-v02 --profile gpt.pika --wait", + "", + "Boundary:", + " apply syncs YAML-declared Secret/config, publishes runtime GitOps using the current manager image, refreshes Argo and restarts the manager; it does not rebuild images or create a PipelineRun.", + ].join("\n"); +} + +export async function runProviderProfileCommand(config: UniDeskConfig, action: string | undefined, args: string[]): Promise> { + const options = parseProviderProfileOptions(args); + if (action === "plan") return providerProfilePlan(options); + if (action === "status") return providerProfileStatus(config, options); + if (action === "apply") return providerProfileApply(config, options); + if (action === "validate") return providerProfileValidate(config, options); + throw new Error(`unsupported provider-profile action: ${action ?? "(empty)"}`); +} + +function resolveProviderProfile(options: ProviderProfileOptions): { configPath: string; spec: AgentRunLaneSpec; profile: string; secrets: AgentRunLaneSecretSpec[] } { + if (options.profile === null) throw new Error("provider-profile requires --profile"); + const target = resolveAgentRunLaneTarget(options); + const secrets = target.spec.secrets.filter((secret) => secret.providerCredentialProfile === options.profile); + if (secrets.length === 0) { + const declared = agentRunProviderCredentialRefs(target.spec).map((item) => item.profile); + throw new Error(`provider profile ${options.profile} is not declared in ${target.configPath}; declared profiles: ${declared.join(", ") || "(none)"}`); + } + return { ...target, profile: options.profile, secrets }; +} + +function providerProfilePlan(options: ProviderProfileOptions): Record { + const { configPath, spec, profile, secrets } = resolveProviderProfile(options); + const values = secrets.map((secret) => { + const source = collectLaneSecretSources(spec).find((item) => item.id === secret.id); + const sourceValue = source === undefined ? null : readSecretSourceValue(spec, source); + return { + id: secret.id, + sourceRef: secret.sourceRef, + sourceMode: secret.sourceMode, + targetRef: secret.targetRef, + key: secret.targetRef.key, + codexConfig: secret.codexConfig === null ? null : codexConfigSummary(secret.codexConfig), + sourcePath: sourceValue?.redactedPath ?? null, + fingerprint: sourceValue?.fingerprint ?? null, + valueBytes: sourceValue?.valueBytes ?? null, + valuesPrinted: false, + }; + }); + return { + ok: true, + command: "agentrun provider-profile plan", + mode: "yaml-first-online-profile", + mutation: false, + configPath, + target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec), + profile: providerProfileSummary(spec, profile, secrets), + plan: { + secretCount: values.length, + secrets: values, + runtime: { + managerDeployment: spec.runtime.managerDeployment, + runnerEgressProxyUrl: spec.deployment.runner.egressProxyUrl, + runnerNoProxyExtra: spec.deployment.runner.noProxyExtra, + valuesPrinted: false, + }, + applySteps: ["secret-sync", "gitops-publish-current-image", "argocd-refresh-sync", "manager-rollout-restart"], + rebuildImages: false, + pipelineRun: false, + valuesPrinted: false, + }, + next: { + apply: `bun scripts/cli.ts agentrun provider-profile apply --node ${spec.nodeId} --lane ${spec.lane} --profile ${profile} --confirm`, + status: `bun scripts/cli.ts agentrun provider-profile status --node ${spec.nodeId} --lane ${spec.lane} --profile ${profile}`, + }, + valuesPrinted: false, + }; +} + +async function providerProfileStatus(config: UniDeskConfig, options: ProviderProfileOptions): Promise> { + const { configPath, spec, profile, secrets } = resolveProviderProfile(options); + const live = await capture(config, spec.nodeKubeRoute, ["sh", "--", providerProfileLiveStatusScript(spec, secrets)]); + const payload = captureJsonPayload(live); + return { + ok: live.exitCode === 0 && payload.ok !== false, + command: "agentrun provider-profile status", + mode: "yaml-first-online-profile", + mutation: false, + configPath, + target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec), + profile: providerProfileSummary(spec, profile, secrets), + yamlDesired: { + secrets: secrets.map((secret) => ({ + id: secret.id, + targetRef: secret.targetRef, + key: secret.targetRef.key, + codexConfig: secret.codexConfig === null ? null : codexConfigSummary(secret.codexConfig), + valuesPrinted: false, + })), + runnerNoProxyExtra: spec.deployment.runner.noProxyExtra, + runnerEgressProxyUrl: spec.deployment.runner.egressProxyUrl, + valuesPrinted: false, + }, + live: payload, + capture: compactCapture(live, { full: options.full || options.raw || live.exitCode !== 0, stdoutTailChars: 3000, stderrTailChars: 3000 }), + next: { + apply: `bun scripts/cli.ts agentrun provider-profile apply --node ${spec.nodeId} --lane ${spec.lane} --profile ${profile} --confirm`, + validate: `bun scripts/cli.ts agentrun provider-profile validate --node ${spec.nodeId} --lane ${spec.lane} --profile ${profile} --wait`, + }, + valuesPrinted: false, + }; +} + +async function providerProfileApply(config: UniDeskConfig, options: ProviderProfileOptions): Promise> { + const { configPath, spec, profile, secrets } = resolveProviderProfile(options); + if (options.dryRun || !options.confirm) return providerProfilePlan({ ...options, dryRun: true, confirm: false }); + const secretIds = secrets.map((secret) => secret.id); + const allSources = collectLaneSecretSources(spec); + const selectedSources = allSources.filter((source) => secretIds.includes(source.id)); + const secretValues = selectedSources.map((source) => ({ spec: source, value: readSecretSourceValue(spec, source) })); + const secretSync = await capture(config, spec.nodeKubeRoute, ["sh", "--", secretSyncScript(spec, secretValues.map(({ spec: item, value }) => ({ targetRef: item.targetRef, value: value.value })))]); + const secretPayload = captureJsonPayload(secretSync); + if (secretSync.exitCode !== 0 || secretPayload.ok === false) { + return applyFailure(configPath, spec, profile, "secret-sync", secretPayload, secretSync); + } + const liveBefore = await capture(config, spec.nodeKubeRoute, ["sh", "--", currentManagerImageScript(spec)]); + const liveBeforePayload = captureJsonPayload(liveBefore); + const sourceCommit = stringOrNull(liveBeforePayload.sourceCommit); + const envIdentity = stringOrNull(liveBeforePayload.envIdentity); + const digest = stringOrNull(liveBeforePayload.digest); + if (liveBefore.exitCode !== 0 || sourceCommit === null || envIdentity === null || digest === null) { + return applyFailure(configPath, spec, profile, "current-image-resolve", liveBeforePayload, liveBefore); + } + const image = agentRunImageArtifact(spec, { sourceCommit, envIdentity, digest, status: "reused" }); + const renderedFiles = renderAgentRunGitopsFiles(spec, { sourceCommit, image }); + const publish = await publishProfileGitops(config, spec, sourceCommit, renderedFiles); + if (publish.ok !== true) { + return { ok: false, command: "agentrun provider-profile apply", mode: "confirmed-online-profile", mutation: true, configPath, target: compactAgentRunLaneStatusTarget(spec), profile, phase: "gitops-publish", result: publish, valuesPrinted: false }; + } + const argo = await capture(config, spec.nodeKubeRoute, ["sh", "--", providerProfileArgoRefreshSyncScript(spec)]); + const argoPayload = captureJsonPayload(argo); + if (argo.exitCode !== 0 || argoPayload.ok === false) { + return applyFailure(configPath, spec, profile, "argocd-refresh-sync", argoPayload, argo); + } + const restart = await capture(config, spec.nodeKubeRoute, ["sh", "--", restartYamlLaneScript(spec)]); + const restartPayload = captureJsonPayload(restart); + if (restart.exitCode !== 0 || restartPayload.ok === false) { + return applyFailure(configPath, spec, profile, "manager-restart", restartPayload, restart); + } + const liveAfter = await capture(config, spec.nodeKubeRoute, ["sh", "--", providerProfileLiveStatusScript(spec, secrets)]); + const liveAfterPayload = captureJsonPayload(liveAfter); + return { + ok: liveAfter.exitCode === 0 && liveAfterPayload.ok !== false, + command: "agentrun provider-profile apply", + mode: "confirmed-online-profile", + mutation: true, + configPath, + target: compactAgentRunLaneStatusTarget(spec), + profile: providerProfileSummary(spec, profile, secrets), + steps: { + secretSync: secretPayload, + gitops: publish.payload, + argo: argoPayload, + restart: restartPayload, + live: liveAfterPayload, + valuesPrinted: false, + }, + renderedFiles: { + count: renderedFiles.length, + digest: renderedFilesDigest(renderedFiles), + valuesPrinted: false, + }, + image: { sourceCommit, envIdentity, digest, status: "reused", valuesPrinted: false }, + rebuildImages: false, + pipelineRun: false, + captures: options.full || options.raw || liveAfter.exitCode !== 0 ? { + secretSync: compactCapture(secretSync, { full: options.full || options.raw || secretSync.exitCode !== 0 }), + argo: compactCapture(argo, { full: options.full || options.raw || argo.exitCode !== 0 }), + restart: compactCapture(restart, { full: options.full || options.raw || restart.exitCode !== 0 }), + live: compactCapture(liveAfter, { full: options.full || options.raw || liveAfter.exitCode !== 0 }), + } : undefined, + next: { + status: `bun scripts/cli.ts agentrun provider-profile status --node ${spec.nodeId} --lane ${spec.lane} --profile ${profile}`, + validate: `bun scripts/cli.ts agentrun provider-profile validate --node ${spec.nodeId} --lane ${spec.lane} --profile ${profile} --wait`, + }, + valuesPrinted: false, + }; +} + +async function providerProfileValidate(config: UniDeskConfig, options: ProviderProfileOptions): Promise> { + const { configPath, spec, profile, secrets } = resolveProviderProfile(options); + const prompt = options.prompt ?? `请只回复:${profile} upstream OK`; + const live = await capture(config, spec.nodeKubeRoute, ["sh", "--", providerProfileLiveStatusScript(spec, secrets)]); + const livePayload = captureJsonPayload(live); + const canRun = live.exitCode === 0 && livePayload.ok !== false; + return { + ok: canRun, + command: "agentrun provider-profile validate", + mode: "profile-runtime-preflight", + mutation: false, + configPath, + target: compactAgentRunLaneStatusTarget(spec), + profile: providerProfileSummary(spec, profile, secrets), + promptBytes: Buffer.byteLength(prompt, "utf8"), + live: livePayload, + note: "This command validates YAML/live profile wiring. User-entry Web validation still runs through HWLAB web-probe so it uses the same Workbench path.", + webProbeNext: `bun scripts/cli.ts web-probe observe command --type sendPrompt --provider ${profile} --text '${prompt.replaceAll("'", "'\\''")}'`, + valuesPrinted: false, + }; +} + +function applyFailure(configPath: string, spec: AgentRunLaneSpec, profile: string, phase: string, payload: Record, captureResult: Awaited>): Record { + return { + ok: false, + command: "agentrun provider-profile apply", + mode: "confirmed-online-profile", + mutation: true, + configPath, + target: compactAgentRunLaneStatusTarget(spec), + profile, + phase, + result: payload, + capture: compactCapture(captureResult, { full: true, stdoutTailChars: 4000, stderrTailChars: 4000 }), + valuesPrinted: false, + }; +} + +async function publishProfileGitops(config: UniDeskConfig, spec: AgentRunLaneSpec, sourceCommit: string, files: readonly { path: string; content: string }[]): Promise & { ok: boolean; payload: Record }> { + const jobName = `profile-gitops-${spec.nodeId.toLowerCase()}-${spec.lane}-${Date.now().toString(36)}`.slice(0, 63); + const manifest = yamlLaneGitopsPublishJobManifest(spec, files, jobName); + const created = await capture(config, spec.nodeKubeRoute, ["sh", "--", createYamlLaneJobScript(spec.gitMirror.namespace, jobName, manifest)]); + if (created.exitCode !== 0) return { ok: false, payload: { ok: false, status: "create-failed", jobName, valuesPrinted: false }, capture: compactCapture(created, { full: true }), valuesPrinted: false }; + const startedAt = Date.now(); + let polls = 0; + let lastPayload: Record = {}; + while (Date.now() - startedAt < 300_000) { + polls += 1; + const probe = await capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneJobProbeScript(spec.gitMirror.namespace, jobName)]); + const probePayload = captureJsonPayload(probe); + const publishPayload = yamlLaneGitopsPublishPayloadFromProbe(probePayload); + if (Object.keys(publishPayload).length > 0) lastPayload = publishPayload; + progressEvent("agentrun.provider-profile.gitops-publish.progress", { node: spec.nodeId, lane: spec.lane, sourceCommit, jobName, polls, status: stringOrNull(publishPayload.status) ?? "running", valuesPrinted: false }); + if (probePayload.succeeded === true) return { ok: publishPayload.ok === true, payload: publishPayload, jobName, polls, elapsedMs: Date.now() - startedAt, probe: compactCapture(probe), valuesPrinted: false }; + if (probePayload.failed === true) return { ok: false, payload: { ...lastPayload, ok: false, status: "failed", jobName, valuesPrinted: false }, jobName, polls, elapsedMs: Date.now() - startedAt, probe: compactCapture(probe, { full: true }), valuesPrinted: false }; + await sleep(3_000); + } + return { ok: false, payload: { ...lastPayload, ok: false, status: "timeout", jobName, valuesPrinted: false }, jobName, polls, elapsedMs: Date.now() - startedAt, valuesPrinted: false }; +} + +function providerProfileSummary(spec: AgentRunLaneSpec, profile: string, secrets: readonly AgentRunLaneSecretSpec[]): Record { + const credential = agentRunProviderCredentialRefs(spec, profile)[0] ?? null; + return { + profile, + declared: true, + backendProfile: profile === "gpt.pika" ? "gpt-pika" : profile, + secretIds: secrets.map((secret) => secret.id), + secretRef: credential?.secretRef ?? null, + configuredKeys: credential?.secretRef.keys ?? secrets.map((secret) => secret.targetRef.key), + valuesPrinted: false, + }; +} + +function codexConfigSummary(config: NonNullable): Record { + return { + modelProvider: config.modelProvider, + model: config.model, + reviewModel: config.reviewModel, + modelProviderCount: config.modelProviders.length, + providers: config.modelProviders.map((provider) => ({ + id: provider.id, + baseUrl: provider.baseUrl, + wireApi: provider.wireApi, + requiresOpenaiAuth: provider.requiresOpenaiAuth, + })), + valuesPrinted: false, + }; +} + +function currentManagerImageScript(spec: AgentRunLaneSpec): string { + return [ + "set -eu", + `namespace=${shQuote(spec.runtime.namespace)}`, + `deployment=${shQuote(spec.runtime.managerDeployment)}`, + "kubectl -n \"$namespace\" get deployment \"$deployment\" -o json >/tmp/agentrun-provider-profile-current-manager.json", + "node <<'NODE'", + "const fs = require('node:fs');", + " const j = JSON.parse(fs.readFileSync('/tmp/agentrun-provider-profile-current-manager.json', 'utf8'));", + " const c = (j.spec?.template?.spec?.containers || [])[0] || {};", + " const env = new Map((c.env || []).map((e) => [e.name, e.value || null]));", + " const image = c.image || '';", + " const digest = image.includes('@') ? image.split('@').pop() : null;", + " console.log(JSON.stringify({ ok: Boolean(env.get('AGENTRUN_SOURCE_COMMIT') && env.get('AGENTRUN_ENV_IDENTITY') && digest), sourceCommit: env.get('AGENTRUN_SOURCE_COMMIT'), envIdentity: env.get('AGENTRUN_ENV_IDENTITY'), digest, imageStatus: 'reused', valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + +function providerProfileLiveStatusScript(spec: AgentRunLaneSpec, secrets: readonly AgentRunLaneSecretSpec[]): string { + const refs = Buffer.from(JSON.stringify(secrets.map((secret) => secret.targetRef)), "utf8").toString("base64"); + return [ + "set -eu", + `namespace=${shQuote(spec.runtime.namespace)}`, + `deployment=${shQuote(spec.runtime.managerDeployment)}`, + `refs_b64=${shQuote(refs)}`, + "kubectl -n \"$namespace\" get deployment \"$deployment\" -o json >/tmp/agentrun-provider-profile-deploy.json", + "REFS_B64=\"$refs_b64\" node <<'NODE'", + "const fs = require('node:fs');", + "const cp = require('node:child_process');", + "const refs = JSON.parse(Buffer.from(process.env.REFS_B64 || '', 'base64').toString('utf8'));", + "const deployment = JSON.parse(fs.readFileSync('/tmp/agentrun-provider-profile-deploy.json', 'utf8'));", + "const container = deployment.spec?.template?.spec?.containers?.[0] || {};", + "const env = new Map((container.env || []).map((e) => [e.name, e.value || null]));", + "const noProxy = env.get('AGENTRUN_RUNNER_NO_PROXY_EXTRA') || '';", + "const secretItems = refs.map((ref) => {", + " const out = cp.spawnSync('kubectl', ['-n', ref.namespace, 'get', 'secret', ref.name, '-o', 'json'], { encoding: 'utf8' });", + " if (out.status !== 0) return { namespace: ref.namespace, name: ref.name, key: ref.key, present: false, keyPresent: false, valuesPrinted: false };", + " const secret = JSON.parse(out.stdout);", + " const raw = secret.data?.[ref.key] || '';", + " const bytes = raw ? Buffer.from(raw, 'base64').length : 0;", + " const fingerprint = raw ? 'sha256:' + require('node:crypto').createHash('sha256').update(Buffer.from(raw, 'base64')).digest('hex') : null;", + " return { namespace: ref.namespace, name: ref.name, key: ref.key, present: true, keyPresent: Boolean(raw), valueBytes: bytes, fingerprint, valuesPrinted: false };", + "});", + "console.log(JSON.stringify({ ok: secretItems.every((item) => item.keyPresent), manager: { deploymentExists: Boolean(deployment.metadata?.name), readyReplicas: deployment.status?.readyReplicas ?? null, replicas: deployment.status?.replicas ?? null, sourceCommit: env.get('AGENTRUN_SOURCE_COMMIT'), envIdentity: env.get('AGENTRUN_ENV_IDENTITY'), runnerEgressProxyUrl: env.get('AGENTRUN_RUNNER_EGRESS_PROXY_URL'), runnerNoProxyExtra: noProxy, noProxyContainsHyue: noProxy.includes('hyueapi.com'), noProxyContainsPikapython: noProxy.includes('api.pikapython.com') || noProxy.includes('.pikapython.com'), valuesPrinted: false }, secrets: { count: secretItems.length, ready: secretItems.every((item) => item.keyPresent), items: secretItems, valuesPrinted: false }, valuesPrinted: false }));", + "NODE", + ].join("\n"); +} + +function providerProfileArgoRefreshSyncScript(spec: AgentRunLaneSpec): string { + return [ + refreshYamlLaneScript(spec), + `namespace=${shQuote(spec.gitops.argoNamespace)}`, + `application=${shQuote(spec.gitops.argoApplication)}`, + "kubectl -n \"$namespace\" patch application \"$application\" --type=merge -p '{\"operation\":{\"sync\":{\"syncStrategy\":{\"hook\":{}}}}}' >/tmp/agentrun-provider-profile-argo-sync.out 2>/tmp/agentrun-provider-profile-argo-sync.err || true", + "for i in $(seq 1 60); do", + " app_json=$(kubectl -n \"$namespace\" get application \"$application\" -o json 2>/dev/null || printf '{}')", + " APP_JSON=\"$app_json\" node <<'NODE' >/tmp/agentrun-provider-profile-argo-state.json", + "const app = JSON.parse(process.env.APP_JSON || '{}');", + "const sync = app.status?.sync || {};", + "const health = app.status?.health || {};", + "const op = app.status?.operationState || null;", + "console.log(JSON.stringify({sync: sync.status || null, health: health.status || null, revision: sync.revision || null, operationPhase: op?.phase || null, message: op?.message || null}));", + "NODE", + " if node -e \"const s=require('fs').readFileSync('/tmp/agentrun-provider-profile-argo-state.json','utf8'); const j=JSON.parse(s); process.exit(j.sync==='Synced' && j.health==='Healthy' && (!j.operationPhase || j.operationPhase==='Succeeded') ? 0 : 1)\"; then break; fi", + " sleep 3", + "done", + "cat /tmp/agentrun-provider-profile-argo-state.json | node -e \"let s='';process.stdin.on('data',d=>s+=d);process.stdin.on('end',()=>{const j=JSON.parse(s||'{}'); console.log(JSON.stringify({ok:j.sync==='Synced' && j.health==='Healthy', ...j, valuesPrinted:false}));})\"", + ].join("\n"); +} diff --git a/scripts/src/hwlab-node-web-observe-runner-control-source.ts b/scripts/src/hwlab-node-web-observe-runner-control-source.ts index 0b37d351..00ad193b 100644 --- a/scripts/src/hwlab-node-web-observe-runner-control-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-control-source.ts @@ -93,6 +93,7 @@ async function processCommand(command) { case "goto": return withObserverSync(await gotoTarget(command.path || command.url || targetPath), "goto"); case "newSession": return withObserverSync(await createSessionFromUi(), "newSession"); case "sendPrompt": return withObserverSync(await sendPrompt(String(command.text || ""), { + provider: command.provider, expectedAction: "turn", responsePath: "/v1/agent/chat", alternateResponsePaths: ["/v1/agent/chat/steer"], @@ -1593,9 +1594,11 @@ async function forceRecoverControlPageForCommand(reason) { async function sendPrompt(text, options = {}) { if (text.trim().length === 0) throw new Error("sendPrompt requires non-empty text"); const responsePath = options.responsePath || "/v1/agent/chat"; + const requestedProvider = String(options.provider || "").trim(); const controlRecovery = await ensureControlPageResponsiveForCommand("sendPrompt"); const beforeUrl = currentPageUrl(); const beforeEvidence = await promptSideEffectSnapshot(); + const providerSelection = requestedProvider ? await selectProvider(requestedProvider) : null; let editor = null; let composerRecovery = null; let editorWaitError = null; @@ -1618,7 +1621,7 @@ async function sendPrompt(text, options = {}) { if (!editor) { const snapshot = await controlPageLivenessSnapshot("sendPrompt-composer-editor-missing-final", 3000); const error = new Error("sendPrompt composer editor did not become visible"); - error.details = { beforeUrl, afterUrl: currentPageUrl(), controlRecovery, composerRecovery, snapshot, editorWaitError: errorSummary(editorWaitError), pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; + error.details = { beforeUrl, afterUrl: currentPageUrl(), requestedProvider: requestedProvider || null, providerSelection, controlRecovery, composerRecovery, snapshot, editorWaitError: errorSummary(editorWaitError), pageId, pageEpoch: controlPageEpoch, valuesRedacted: true }; throw error; } editor = await fillComposerEditorWithRetry(editor, text, { beforeUrl, controlRecovery }); @@ -1658,6 +1661,8 @@ async function sendPrompt(text, options = {}) { afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), + requestedProvider: requestedProvider || null, + providerSelection, submitted: false, blocked: true, degradedReason: options.noActiveReason || "composer-action-mismatch", @@ -1708,6 +1713,8 @@ async function sendPrompt(text, options = {}) { afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), + requestedProvider: requestedProvider || null, + providerSelection, chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: true, sideEffect }, controlRecovery, pageId, @@ -1720,6 +1727,8 @@ async function sendPrompt(text, options = {}) { afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), + requestedProvider: requestedProvider || null, + providerSelection, chatSubmit: { status: null, statusText: null, urlPath: responsePath, waitError: chatResponse.waitError, sideEffectObserved: false, sideEffect }, controlRecovery, pageId, @@ -1755,6 +1764,8 @@ async function sendPrompt(text, options = {}) { afterUrl: currentPageUrl(), textHash: sha256Text(text), textBytes: Buffer.byteLength(text), + requestedProvider: requestedProvider || null, + providerSelection, chatSubmit: { status: chatStatus, statusText: chatResponse.statusText(),