refactor: migrate v02 cloud api to TypeScript
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
import { createCloudApiServer } from "../../internal/cloud/server.mjs";
|
||||
#!/usr/bin/env bun
|
||||
import { createCloudApiServer } from "../../internal/cloud/server.ts";
|
||||
import { applyCloudApiImageEnv, parsePort, parseTimeout } from "./runtime-options.ts";
|
||||
|
||||
const host = process.env.HWLAB_CLOUD_API_HOST || "0.0.0.0";
|
||||
const port = parsePort(process.env.HWLAB_CLOUD_API_PORT, parsePort(process.env.PORT, 6667));
|
||||
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA;
|
||||
const codeAgentTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_TIMEOUT_MS, 1200000, {
|
||||
min: 1000,
|
||||
max: 2400000
|
||||
@@ -12,32 +12,13 @@ const codeAgentHardTimeoutMs = parseTimeout(process.env.HWLAB_CODE_AGENT_HARD_TI
|
||||
min: codeAgentTimeoutMs,
|
||||
max: 3600000
|
||||
});
|
||||
|
||||
if (!process.env.HWLAB_IMAGE && commitId) {
|
||||
process.env.HWLAB_IMAGE = `ghcr.io/pikastech/hwlab-cloud-api:${commitId.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
if (!process.env.HWLAB_IMAGE_TAG && commitId) {
|
||||
process.env.HWLAB_IMAGE_TAG = commitId.slice(0, 7);
|
||||
}
|
||||
applyCloudApiImageEnv(process.env);
|
||||
|
||||
const server = createCloudApiServer({
|
||||
codeAgentTimeoutMs,
|
||||
codeAgentHardTimeoutMs
|
||||
});
|
||||
|
||||
function parseTimeout(value, fallback, { min, max }) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
if (!Number.isInteger(parsed)) return fallback;
|
||||
return Math.min(Math.max(parsed, min), max);
|
||||
}
|
||||
|
||||
function parsePort(value, fallback) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed >= 65536) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
server.listen(port, host, () => {
|
||||
const address = server.address();
|
||||
const resolvedPort = typeof address === "object" && address ? address.port : port;
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
formatDevRuntimeMigrationFailure,
|
||||
runDevRuntimeMigrationCli
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
formatDevRuntimeProvisioningFailure,
|
||||
runDevRuntimeProvisioningCli
|
||||
@@ -0,0 +1,38 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { applyCloudApiImageEnv, parsePort, parseTimeout } from "./runtime-options.ts";
|
||||
|
||||
test("parsePort preserves current hwlab-cloud-api port fallback behavior", () => {
|
||||
assert.equal(parsePort(undefined, 6667), 6667);
|
||||
assert.equal(parsePort("", 6667), 6667);
|
||||
assert.equal(parsePort("abc", 6667), 6667);
|
||||
assert.equal(parsePort("-1", 6667), 6667);
|
||||
assert.equal(parsePort("65536", 6667), 6667);
|
||||
assert.equal(parsePort("0", 6667), 0);
|
||||
assert.equal(parsePort("19667", 6667), 19667);
|
||||
});
|
||||
|
||||
test("parseTimeout clamps parsed integer values to the configured range", () => {
|
||||
assert.equal(parseTimeout(undefined, 1200000, { min: 1000, max: 2400000 }), 1200000);
|
||||
assert.equal(parseTimeout("abc", 1200000, { min: 1000, max: 2400000 }), 1200000);
|
||||
assert.equal(parseTimeout("1", 1200000, { min: 1000, max: 2400000 }), 1000);
|
||||
assert.equal(parseTimeout("99999999", 1200000, { min: 1000, max: 2400000 }), 2400000);
|
||||
assert.equal(parseTimeout("1500", 1200000, { min: 1000, max: 2400000 }), 1500);
|
||||
});
|
||||
|
||||
test("applyCloudApiImageEnv derives image identity from commit without overriding explicit values", () => {
|
||||
const env = { HWLAB_COMMIT_ID: "abcdef1234567890" };
|
||||
applyCloudApiImageEnv(env);
|
||||
assert.equal(env.HWLAB_IMAGE, "ghcr.io/pikastech/hwlab-cloud-api:abcdef1");
|
||||
assert.equal(env.HWLAB_IMAGE_TAG, "abcdef1");
|
||||
|
||||
const explicit = {
|
||||
HWLAB_GIT_SHA: "11111112222222",
|
||||
HWLAB_IMAGE: "registry.local/custom:tag",
|
||||
HWLAB_IMAGE_TAG: "custom"
|
||||
};
|
||||
applyCloudApiImageEnv(explicit);
|
||||
assert.equal(explicit.HWLAB_IMAGE, "registry.local/custom:tag");
|
||||
assert.equal(explicit.HWLAB_IMAGE_TAG, "custom");
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export function parseTimeout(value, fallback, { min, max }) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
if (!Number.isInteger(parsed)) return fallback;
|
||||
return Math.min(Math.max(parsed, min), max);
|
||||
}
|
||||
|
||||
export function parsePort(value, fallback) {
|
||||
const parsed = Number.parseInt(value || "", 10);
|
||||
if (!Number.isInteger(parsed) || parsed < 0 || parsed >= 65536) return fallback;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function applyCloudApiImageEnv(env = process.env) {
|
||||
const commitId = env.HWLAB_COMMIT_ID || env.HWLAB_GIT_SHA;
|
||||
if (!env.HWLAB_IMAGE && commitId) {
|
||||
env.HWLAB_IMAGE = `ghcr.io/pikastech/hwlab-cloud-api:${commitId.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
if (!env.HWLAB_IMAGE_TAG && commitId) {
|
||||
env.HWLAB_IMAGE_TAG = commitId.slice(0, 7);
|
||||
}
|
||||
}
|
||||
@@ -97,7 +97,9 @@
|
||||
"digest": "sha256:fe724e72e7cea3c93abe17085c4d885c78d34d18eb2a3c01f987d1971e2f4425",
|
||||
"reusedFrom": "7498160c36db86db76d3c1b103e397f2b2304248136d3b79eafe1ab69d4abeff"
|
||||
},
|
||||
"reusedFrom": "7498160c36db86db76d3c1b103e397f2b2304248136d3b79eafe1ab69d4abeff"
|
||||
"reusedFrom": "7498160c36db86db76d3c1b103e397f2b2304248136d3b79eafe1ab69d4abeff",
|
||||
"runtimeKind": "bun-command",
|
||||
"entrypoint": "cmd/hwlab-cloud-api/main.ts"
|
||||
},
|
||||
{
|
||||
"serviceId": "hwlab-cloud-web",
|
||||
@@ -578,10 +580,10 @@
|
||||
"publishEnabled": true,
|
||||
"artifactRequired": true,
|
||||
"artifactScope": "required",
|
||||
"runtimeKind": "node-command",
|
||||
"runtimeKind": "bun-command",
|
||||
"implementationState": "repo-entrypoint",
|
||||
"sourceState": "source-present",
|
||||
"entrypoint": "cmd/hwlab-cloud-api/main.mjs",
|
||||
"entrypoint": "cmd/hwlab-cloud-api/main.ts",
|
||||
"disabledReason": null
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,7 @@ Secret 或 token。
|
||||
`/v1/agent/chat`。
|
||||
- DEV API/edge 入口是 `http://74.48.78.17:17667/`;它不能替代 Workbench
|
||||
同源聊天入口的真实回复证据。
|
||||
- `internal/cloud/code-agent-chat.mjs` 是 `/v1/agent/chat` 的后端处理入口。
|
||||
- `internal/cloud/code-agent-chat.ts` 是 `/v1/agent/chat` 的后端处理入口。
|
||||
- `scripts/code-agent-chat-smoke.mjs` 是 Code Agent chat schema 与 readiness
|
||||
合同检查。
|
||||
- `scripts/dev-cloud-workbench-smoke.mjs --static` 只验证 Workbench 源码合同和
|
||||
|
||||
@@ -193,7 +193,7 @@ When health reports:
|
||||
- `runtime.status: "degraded"`
|
||||
|
||||
the Cloud API is using the process-local runtime store in
|
||||
`internal/db/runtime-store.mjs`. Gateway sessions, box resources, operations,
|
||||
`internal/db/runtime-store.ts`. Gateway sessions, box resources, operations,
|
||||
audit events, and evidence records accepted through the runtime can be lost on
|
||||
pod restart, redeploy, or scale-out, and cannot be treated as durable M3/M4/M5
|
||||
evidence. Users may see accepted operations or evidence disappear after a
|
||||
@@ -238,9 +238,9 @@ below are true.
|
||||
The cloud-api image owns a stable DEV DB provisioning entrypoint:
|
||||
|
||||
```sh
|
||||
node cmd/hwlab-cloud-api/provision.mjs --check
|
||||
node cmd/hwlab-cloud-api/provision.mjs --dry-run --allow-live-db-read --confirm-dev --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json
|
||||
node cmd/hwlab-cloud-api/provision.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json
|
||||
bun cmd/hwlab-cloud-api/provision.ts --check
|
||||
bun cmd/hwlab-cloud-api/provision.ts --dry-run --allow-live-db-read --confirm-dev --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json
|
||||
bun cmd/hwlab-cloud-api/provision.ts --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json
|
||||
```
|
||||
|
||||
This is the preferred image-internal operator command for `hwlab-cloud-api`.
|
||||
@@ -288,9 +288,9 @@ untracked manual DB write.
|
||||
The cloud-api image owns a stable runtime migration entrypoint:
|
||||
|
||||
```sh
|
||||
node cmd/hwlab-cloud-api/migrate.mjs --check
|
||||
node cmd/hwlab-cloud-api/migrate.mjs --dry-run --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json
|
||||
node cmd/hwlab-cloud-api/migrate.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json
|
||||
bun cmd/hwlab-cloud-api/migrate.ts --check
|
||||
bun cmd/hwlab-cloud-api/migrate.ts --dry-run --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json
|
||||
bun cmd/hwlab-cloud-api/migrate.ts --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json
|
||||
```
|
||||
|
||||
This is the preferred image-internal operator command for `hwlab-cloud-api`.
|
||||
@@ -298,7 +298,7 @@ It delegates to the same repo-owned migration implementation as the root
|
||||
script, so there is only one migration contract and one output format. The
|
||||
cloud-api artifact build copies `cmd/`, `scripts/`, `internal/`, `package.json`,
|
||||
and runtime dependencies into the image; the DEV CD runtime migration Job uses
|
||||
`node cmd/hwlab-cloud-api/migrate.mjs` from the current cloud-api image and
|
||||
`bun cmd/hwlab-cloud-api/migrate.ts` from the current cloud-api image and
|
||||
injects DB inputs only through SecretRef-backed env.
|
||||
|
||||
The repo-level compatibility entrypoint remains:
|
||||
|
||||
@@ -94,7 +94,7 @@ SCRIPT
|
||||
| --- | --- |
|
||||
| Deployment | `hwlab-dev/hwlab-cloud-api` |
|
||||
| ConfigMap | `hwlab-cloud-api-code-agent-hotfix` |
|
||||
| 覆盖文件 | `/app/internal/cloud/code-agent-chat.mjs` |
|
||||
| 覆盖文件 | `/app/internal/cloud/code-agent-chat.ts` |
|
||||
| subPath | `code-agent-chat.mjs` |
|
||||
| marker | `runtime-hotfix-pc-gateway-shell` |
|
||||
| annotation | `hwlab.pikastech.local/pc-gateway-shell-hotfix` |
|
||||
@@ -125,7 +125,7 @@ node scripts/dev-runtime-hotfix-audit.mjs --collect-readonly --pretty
|
||||
|
||||
## 回滚口径
|
||||
|
||||
优先回滚方式是用源码化 artifact/CD 覆盖 runtime hotfix:#460/#461 合并并发布后,Deployment 应消费正式镜像,不再通过 ConfigMap 覆盖 `/app/internal/cloud/code-agent-chat.mjs`。注意 `kubectl apply -k` 可能保留运行面 patch 写入、且源码 desired-state 不拥有的 hotfix `volumes`、`volumeMounts` 或 template annotations;正式 DEV CD apply 应先识别这种 unmanaged hotfix 覆盖,删除对应 desired Deployment,再由同一次 apply 从源码重新创建。
|
||||
优先回滚方式是用源码化 artifact/CD 覆盖 runtime hotfix:#460/#461 合并并发布后,Deployment 应消费正式镜像,不再通过 ConfigMap 覆盖 `/app/internal/cloud/code-agent-chat.ts`。注意 `kubectl apply -k` 可能保留运行面 patch 写入、且源码 desired-state 不拥有的 hotfix `volumes`、`volumeMounts` 或 template annotations;正式 DEV CD apply 应先识别这种 unmanaged hotfix 覆盖,删除对应 desired Deployment,再由同一次 apply 从源码重新创建。
|
||||
|
||||
只读确认口径是通过当前 G14 k3s route 读取目标对象;不要从 master server 裸跑 `kubectl`,也不要把 D601 kubeconfig 当作当前 DEV/v0.2 控制面:
|
||||
|
||||
@@ -139,7 +139,7 @@ node scripts/dev-runtime-hotfix-audit.mjs --collect-readonly --pretty
|
||||
如果必须直接移除运行态覆盖,授权操作者应先从 `kubectl -n hwlab-dev get deployment hwlab-cloud-api -o json` 中定位精确 indexes,再移除:
|
||||
|
||||
- 指向 hotfix ConfigMap 的 `spec.template.spec.volumes[]`;
|
||||
- 指向 `/app/internal/cloud/code-agent-chat.mjs` 的 `containers[].volumeMounts[]`;
|
||||
- 指向 `/app/internal/cloud/code-agent-chat.ts` 的 `containers[].volumeMounts[]`;
|
||||
- `hwlab.pikastech.local/pc-gateway-shell-hotfix` 等 hotfix annotation;
|
||||
- 不再需要的 `hwlab-cloud-api-code-agent-hotfix` ConfigMap。
|
||||
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
|
||||
## 内部架构
|
||||
|
||||
- `cmd/hwlab-cloud-api/main.mjs` 负责启动 HTTP server、解析端口和 Code Agent timeout。
|
||||
- `internal/cloud/server.mjs` 负责 HTTP route、REST/RPC bridge、health、live-builds、device-pod proxy、gateway poll/result 和 Code Agent chat。
|
||||
- `internal/db/runtime-store.mjs` 和 `internal/cloud/db-contract.mjs` 负责 Postgres runtime store 与 readiness 分层。
|
||||
- `internal/cloud/code-agent-*.mjs` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。
|
||||
- `cmd/hwlab-cloud-api/main.ts` 负责启动 HTTP server、解析端口和 Code Agent timeout。
|
||||
- `internal/cloud/server.ts` 负责 HTTP route、REST/RPC bridge、health、live-builds、device-pod proxy、gateway poll/result 和 Code Agent chat。
|
||||
- `internal/db/runtime-store.ts` 和 `internal/cloud/db-contract.ts` 负责 Postgres runtime store 与 readiness 分层。
|
||||
- `internal/cloud/code-agent-*.ts` 负责 Codex stdio session、trace store、result cache、provider profile 和取消/轮询。
|
||||
- 同 Pod sidecar `hwlab-codex-api-forwarder` 监听 `127.0.0.1:49280/responses`,用于 `codex-api` profile 直连 hyueapi,并保持 hyueapi 在 `NO_PROXY` 中。
|
||||
- `hwlab-code-agent-workspace` PVC 挂载到 `/workspace/hwlab`,用于长会话 workspace;它是 cloud-api 运行资源,不是独立用户入口。
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
## 内部架构
|
||||
|
||||
- `cmd/hwlab-gateway/main.mjs` 维护 gateway state、outbound poll loop、inflight request map 和 command execution。
|
||||
- `internal/cloud/gateway-demo-registry.mjs` 在 cloud-api 内保存 gateway session、队列和 pending result。
|
||||
- `internal/cloud/gateway-demo-registry.ts` 在 cloud-api 内保存 gateway session、队列和 pending result。
|
||||
- command execution 默认关闭;只有显式 `HWLAB_GATEWAY_CMD_EXEC_ENABLED=1` 或 demo open 时才执行 shell。
|
||||
|
||||
## API 接口说明
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder,
|
||||
inspectCodeAgentProviderEnv
|
||||
} from "./code-agent-contract.mjs";
|
||||
} from "./code-agent-contract.ts";
|
||||
import {
|
||||
CODEX_STDIO_BACKEND,
|
||||
CODEX_STDIO_CAPABILITY_LEVEL,
|
||||
@@ -18,19 +18,19 @@ import {
|
||||
CODEX_STDIO_SESSION_MODE,
|
||||
createCodexStdioSessionManager,
|
||||
longLivedSessionGate as codexLongLivedSessionGate
|
||||
} from "./codex-stdio-session.mjs";
|
||||
} from "./codex-stdio-session.ts";
|
||||
import {
|
||||
createCodeAgentTraceRecorder,
|
||||
defaultCodeAgentTraceStore
|
||||
} from "./code-agent-trace-store.mjs";
|
||||
} from "./code-agent-trace-store.ts";
|
||||
import {
|
||||
DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS,
|
||||
createCodeAgentSessionRegistry
|
||||
} from "./code-agent-session-registry.mjs";
|
||||
} from "./code-agent-session-registry.ts";
|
||||
import {
|
||||
codeAgentSessionLifecycleSummary,
|
||||
decorateCodeAgentSession
|
||||
} from "./code-agent-session-lifecycle.mjs";
|
||||
} from "./code-agent-session-lifecycle.ts";
|
||||
const DEFAULT_MODEL = DEV_CODE_AGENT_PROVIDER_CONTRACT.model;
|
||||
const DEFAULT_PROJECT_ID = "prj_hwlab-cloud-workbench";
|
||||
const READONLY_RUNNER_PROVIDER = "codex-readonly-runner";
|
||||
+4
-4
@@ -3,15 +3,15 @@ import { existsSync } from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.mjs";
|
||||
import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.mjs";
|
||||
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.ts";
|
||||
import { handleCodeAgentChat, validateCodeAgentChatSchema } from "./code-agent-chat.ts";
|
||||
import {
|
||||
codexAppServerArgs,
|
||||
codexAppServerProviderBaseUrl,
|
||||
createCodexStdioSessionManager
|
||||
} from "./codex-stdio-session.mjs";
|
||||
} from "./codex-stdio-session.ts";
|
||||
import {
|
||||
classifyCodeAgentChatReadiness,
|
||||
classifyCodexRunnerCapability
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
CODE_AGENT_SESSION_STATUS_ALIASES,
|
||||
codeAgentSessionLifecycleSummary,
|
||||
decorateCodeAgentSession
|
||||
} from "./code-agent-session-lifecycle.mjs";
|
||||
} from "./code-agent-session-lifecycle.ts";
|
||||
|
||||
export const DEFAULT_CODE_AGENT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||
export const DEFAULT_CODE_AGENT_MAX_SESSIONS = 200;
|
||||
@@ -0,0 +1,37 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
||||
|
||||
test("code agent trace store keeps assistant deltas outside event count", () => {
|
||||
const traceStore = createCodeAgentTraceStore({ maxEvents: 6000 });
|
||||
const traceId = "trc_trace-store-assistant-stream";
|
||||
traceStore.append(traceId, { type: "request", status: "accepted", label: "request:accepted" });
|
||||
for (let index = 0; index < 6200; index += 1) {
|
||||
traceStore.appendAssistantDelta(traceId, {
|
||||
itemId: "item_assistant_stream",
|
||||
chunk: `chunk-${index} `,
|
||||
waitingFor: "turn/completed"
|
||||
});
|
||||
}
|
||||
const snapshot = traceStore.snapshot(traceId);
|
||||
assert.equal(snapshot.eventCount, 1);
|
||||
assert.equal(snapshot.events.length, 1);
|
||||
assert.equal(snapshot.events[0].label, "request:accepted");
|
||||
assert.equal(snapshot.assistantStreams.length, 1);
|
||||
assert.equal(snapshot.assistantStreams[0].chunkCount, 6200);
|
||||
assert.equal(snapshot.waitingFor, "turn/completed");
|
||||
});
|
||||
|
||||
test("code agent trace store retains six thousand regular events", () => {
|
||||
const traceStore = createCodeAgentTraceStore({ maxEvents: 6000 });
|
||||
const traceId = "trc_trace-store-regular-events";
|
||||
for (let index = 0; index < 6001; index += 1) {
|
||||
traceStore.append(traceId, { type: "trace", status: "observed", label: `event:${index}` });
|
||||
}
|
||||
const snapshot = traceStore.snapshot(traceId);
|
||||
assert.equal(snapshot.eventCount, 6000);
|
||||
assert.equal(snapshot.events.length, 6000);
|
||||
assert.equal(snapshot.events[0].label, "event:1");
|
||||
assert.equal(snapshot.events.at(-1).label, "event:6000");
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
||||
|
||||
import { CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS } from "./codex-stdio-session.ts";
|
||||
import {
|
||||
appServerActivityTimeoutError,
|
||||
appServerCommandStatus,
|
||||
appServerHardTimeoutError,
|
||||
appServerTerminalStatus,
|
||||
commandExecutionCommandText,
|
||||
commandExecutionErrorText,
|
||||
commandExecutionOutputText,
|
||||
extractAppServerRecord,
|
||||
extractAppServerString,
|
||||
firstNonEmpty,
|
||||
optionalId,
|
||||
redactText,
|
||||
tailText
|
||||
} from "./codex-stdio-session-helpers.ts";
|
||||
|
||||
export function createAppServerTurnState({ traceRecorder, session } = {}) {
|
||||
let threadId = optionalId(session?.threadId);
|
||||
let turnId = null;
|
||||
let assistantText = "";
|
||||
let finalResponse = "";
|
||||
let terminal = null;
|
||||
let terminalResolve;
|
||||
const terminalPromise = new Promise((resolve) => {
|
||||
terminalResolve = resolve;
|
||||
});
|
||||
let lastActivityAt = Date.now();
|
||||
let lastActivityLabel = "turn-state:created";
|
||||
let lastWaitingFor = "app-server-turn";
|
||||
|
||||
function setThreadId(value) {
|
||||
threadId = optionalId(value) ?? threadId;
|
||||
}
|
||||
|
||||
function setTurnId(value) {
|
||||
turnId = optionalId(value) ?? turnId;
|
||||
}
|
||||
|
||||
function activitySnapshot(referenceNow = Date.now()) {
|
||||
return {
|
||||
lastActivityAt: new Date(lastActivityAt).toISOString(),
|
||||
lastActivityLabel,
|
||||
idleMs: Math.max(0, referenceNow - lastActivityAt),
|
||||
waitingFor: lastWaitingFor
|
||||
};
|
||||
}
|
||||
|
||||
function observeActivity({ label = "app-server:activity", waitingFor = null } = {}) {
|
||||
lastActivityAt = Date.now();
|
||||
lastActivityLabel = label;
|
||||
if (waitingFor) lastWaitingFor = waitingFor;
|
||||
return activitySnapshot(lastActivityAt);
|
||||
}
|
||||
|
||||
function appendTrace(event = {}) {
|
||||
const appended = traceRecorder?.append(event);
|
||||
observeActivity({
|
||||
label: appended?.label ?? event.label ?? event.type ?? "trace:event",
|
||||
waitingFor: appended?.waitingFor ?? event.waitingFor ?? null
|
||||
});
|
||||
return appended;
|
||||
}
|
||||
|
||||
function appendProgressTrace(event = {}) {
|
||||
return traceRecorder?.append(event);
|
||||
}
|
||||
|
||||
function hasAssistantOutput() {
|
||||
return Boolean(firstNonEmpty(finalResponse, assistantText));
|
||||
}
|
||||
|
||||
function appendFirstTokenProgressTrace(referenceNow = Date.now()) {
|
||||
const activity = activitySnapshot(referenceNow);
|
||||
if (hasAssistantOutput() || !turnId || terminal) return null;
|
||||
if (!["assistant-message", "turn/completed"].includes(activity.waitingFor)) return null;
|
||||
return appendProgressTrace({
|
||||
type: "turn",
|
||||
status: "running",
|
||||
label: "turn:waiting:first_assistant_token",
|
||||
message: "Codex app-server is waiting for the first assistant token; this progress trace does not reset the backend idle timer.",
|
||||
progressOnly: true,
|
||||
idleMs: activity.idleMs,
|
||||
lastActivityAt: activity.lastActivityAt,
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: "assistant-message"
|
||||
});
|
||||
}
|
||||
|
||||
function finish(status, error = null, extra = {}) {
|
||||
if (terminal) return;
|
||||
terminal = {
|
||||
terminalStatus: appServerTerminalStatus(status),
|
||||
terminalError: error ? redactText(error) : null,
|
||||
threadId,
|
||||
turnId,
|
||||
...activitySnapshot(),
|
||||
...extra
|
||||
};
|
||||
terminalResolve(terminal);
|
||||
}
|
||||
|
||||
function handle(message) {
|
||||
const method = typeof message?.method === "string" ? message.method : "unknown";
|
||||
observeActivity({
|
||||
label: `app-server:${method}`,
|
||||
waitingFor: appServerWaitingForMethod(method)
|
||||
});
|
||||
const params = extractAppServerRecord(message?.params);
|
||||
const item = extractAppServerRecord(params?.item);
|
||||
const turn = extractAppServerRecord(params?.turn);
|
||||
if (method === "thread/started") {
|
||||
setThreadId(extractAppServerString(extractAppServerRecord(params?.thread), "id"));
|
||||
appendTrace({
|
||||
type: "thread",
|
||||
status: "completed",
|
||||
label: "thread:started",
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
waitingFor: "turn/start"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "turn/started") {
|
||||
setTurnId(extractAppServerString(turn, "id"));
|
||||
appendTrace({
|
||||
type: "turn",
|
||||
status: "started",
|
||||
label: "turn:started",
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: "assistant-message"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "item/agentMessage/delta") {
|
||||
const delta = String(params?.delta ?? "");
|
||||
assistantText += delta;
|
||||
if (delta) {
|
||||
traceRecorder?.appendAssistantDelta?.({
|
||||
itemId: optionalId(params?.itemId),
|
||||
chunk: delta,
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: "turn/completed"
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (method === "item/completed" && item?.type === "agentMessage") {
|
||||
if (typeof item.text === "string") finalResponse = item.text;
|
||||
appendTrace({
|
||||
type: "assistant_message",
|
||||
status: "completed",
|
||||
label: "assistant:item_completed",
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: "turn/completed"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "item/started" && item?.type === "commandExecution") {
|
||||
appendCommandExecutionTrace(item, {
|
||||
status: "started",
|
||||
label: "item/commandExecution:started"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "item/completed" && item?.type === "commandExecution") {
|
||||
appendCommandExecutionTrace(item, {
|
||||
status: appServerCommandStatus(item),
|
||||
label: "item/commandExecution:completed"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "item/commandExecution/outputDelta" || method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") {
|
||||
appendTrace({
|
||||
type: method.startsWith("item/reasoning/") ? "reasoning" : "tool_call",
|
||||
status: "output_chunk",
|
||||
label: method,
|
||||
outputSummary: String(params?.delta ?? "").slice(0, 400),
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: "turn/completed"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (method === "error") {
|
||||
const error = extractAppServerRecord(params?.error);
|
||||
const message = typeof error?.message === "string" ? error.message : "Codex app-server error";
|
||||
const willRetry = params?.willRetry === true;
|
||||
setThreadId(extractAppServerString(params, "threadId"));
|
||||
setTurnId(extractAppServerString(params, "turnId"));
|
||||
appendTrace({
|
||||
type: willRetry ? "provider_retry" : "error",
|
||||
status: willRetry ? "retrying" : "failed",
|
||||
label: willRetry ? "app-server:retrying" : "app-server:error",
|
||||
errorCode: willRetry ? "codex_stdio_provider_retry" : "codex_stdio_failed",
|
||||
message: redactText(message),
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: willRetry ? "turn/completed" : "user-retry",
|
||||
terminal: !willRetry
|
||||
});
|
||||
if (!willRetry) finish("failed", message);
|
||||
return;
|
||||
}
|
||||
if (method === "turn/completed") {
|
||||
const status = appServerTerminalStatus(turn?.status);
|
||||
const error = extractAppServerRecord(turn?.error);
|
||||
const message = typeof error?.message === "string" ? error.message : null;
|
||||
appendTrace({
|
||||
type: "turn",
|
||||
status,
|
||||
label: `turn:completed:${status ?? "unknown"}`,
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
errorCode: status === "completed" ? null : "codex_stdio_failed",
|
||||
message: message ? redactText(message) : undefined,
|
||||
terminal: status !== "completed"
|
||||
});
|
||||
finish(status, message);
|
||||
}
|
||||
}
|
||||
|
||||
function appendCommandExecutionTrace(item, { status, label }) {
|
||||
const output = commandExecutionOutputText(item);
|
||||
appendTrace({
|
||||
type: "tool_call",
|
||||
stage: "tool_call",
|
||||
status,
|
||||
label,
|
||||
itemId: optionalId(item.id),
|
||||
toolName: "commandExecution",
|
||||
command: redactText(commandExecutionCommandText(item)),
|
||||
exitCode: Number.isInteger(item.exitCode) ? item.exitCode : undefined,
|
||||
durationMs: typeof item.durationMs === "number" ? item.durationMs : undefined,
|
||||
outputBytes: output.length,
|
||||
stdoutSummary: output ? tailText(redactText(output), 600) : undefined,
|
||||
stderrSummary: commandExecutionErrorText(item),
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: status === "completed" ? "turn/completed" : "commandExecution/completed"
|
||||
});
|
||||
}
|
||||
|
||||
async function wait(timeoutMs, closedPromise, { hardTimeoutMs = null } = {}) {
|
||||
let activityTimer;
|
||||
let hardTimer;
|
||||
let progressTimer;
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
const checkActivity = () => {
|
||||
const now = Date.now();
|
||||
const activity = activitySnapshot(now);
|
||||
if (activity.idleMs >= timeoutMs) {
|
||||
const error = appServerActivityTimeoutError({
|
||||
timeoutMs,
|
||||
idleMs: activity.idleMs,
|
||||
lastActivityAt: activity.lastActivityAt,
|
||||
waitingFor: activity.waitingFor,
|
||||
threadId,
|
||||
turnId,
|
||||
partialAssistant: hasAssistantOutput()
|
||||
});
|
||||
appendTrace({
|
||||
type: "timeout",
|
||||
status: "failed",
|
||||
label: "timeout:no_activity",
|
||||
errorCode: error.code,
|
||||
message: error.message,
|
||||
timeoutMs,
|
||||
idleMs: activity.idleMs,
|
||||
lastActivityAt: activity.lastActivityAt,
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: activity.waitingFor,
|
||||
terminal: true
|
||||
});
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
activityTimer = setTimeout(checkActivity, Math.max(1, timeoutMs - activity.idleMs));
|
||||
};
|
||||
activityTimer = setTimeout(checkActivity, timeoutMs);
|
||||
});
|
||||
const progressPromise = new Promise(() => {
|
||||
const tick = () => {
|
||||
appendFirstTokenProgressTrace();
|
||||
progressTimer = setTimeout(tick, CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS);
|
||||
};
|
||||
progressTimer = setTimeout(tick, CODEX_STDIO_FIRST_TOKEN_PROGRESS_MS);
|
||||
});
|
||||
const hardTimeoutPromise = hardTimeoutMs
|
||||
? new Promise((_, reject) => {
|
||||
hardTimer = setTimeout(() => {
|
||||
const activity = activitySnapshot();
|
||||
const error = appServerHardTimeoutError({
|
||||
hardTimeoutMs,
|
||||
timeoutMs,
|
||||
idleMs: activity.idleMs,
|
||||
lastActivityAt: activity.lastActivityAt,
|
||||
waitingFor: activity.waitingFor,
|
||||
threadId,
|
||||
turnId
|
||||
});
|
||||
appendTrace({
|
||||
type: "timeout",
|
||||
status: "failed",
|
||||
label: "timeout:hard_cap",
|
||||
errorCode: error.code,
|
||||
message: error.message,
|
||||
timeoutMs,
|
||||
hardTimeoutMs,
|
||||
idleMs: activity.idleMs,
|
||||
lastActivityAt: activity.lastActivityAt,
|
||||
sessionId: session?.sessionId,
|
||||
sessionStatus: session?.status,
|
||||
turn: session?.turn,
|
||||
threadId,
|
||||
turnId,
|
||||
waitingFor: activity.waitingFor,
|
||||
terminal: true
|
||||
});
|
||||
reject(error);
|
||||
}, hardTimeoutMs);
|
||||
})
|
||||
: null;
|
||||
const candidates = [terminalPromise];
|
||||
if (closedPromise && typeof closedPromise.then === "function") {
|
||||
candidates.push(closedPromise.then((exit) => {
|
||||
if (terminal) return terminal;
|
||||
return {
|
||||
terminalStatus: "failed",
|
||||
terminalError: hasAssistantOutput()
|
||||
? "Codex app-server transport closed after partial assistant output but before turn/completed."
|
||||
: "Codex app-server transport closed before turn/completed.",
|
||||
threadId,
|
||||
turnId,
|
||||
...activitySnapshot(),
|
||||
appServerExit: exit
|
||||
};
|
||||
}));
|
||||
}
|
||||
try {
|
||||
return await Promise.race([...candidates, timeoutPromise, hardTimeoutPromise, progressPromise].filter(Boolean));
|
||||
} finally {
|
||||
clearTimeout(activityTimer);
|
||||
clearTimeout(hardTimer);
|
||||
clearTimeout(progressTimer);
|
||||
}
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const activity = activitySnapshot();
|
||||
return {
|
||||
threadId,
|
||||
turnId,
|
||||
assistantText: redactText(assistantText),
|
||||
finalResponse: redactText(firstNonEmpty(finalResponse, assistantText)),
|
||||
terminalStatus: terminal?.terminalStatus ?? null,
|
||||
terminalError: terminal?.terminalError ?? null,
|
||||
lastActivityAt: terminal?.lastActivityAt ?? activity.lastActivityAt,
|
||||
idleMs: terminal?.idleMs ?? activity.idleMs,
|
||||
waitingFor: terminal?.waitingFor ?? activity.waitingFor
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
appendTrace,
|
||||
handle,
|
||||
setThreadId,
|
||||
setTurnId,
|
||||
wait,
|
||||
snapshot
|
||||
};
|
||||
}
|
||||
|
||||
function appServerWaitingForMethod(method) {
|
||||
if (method === "thread/started") return "turn/start";
|
||||
if (method === "turn/started") return "assistant-message";
|
||||
if (method === "item/agentMessage/delta") return "turn/completed";
|
||||
if (method === "item/completed") return "turn/completed";
|
||||
if (method === "item/commandExecution/outputDelta") return "turn/completed";
|
||||
if (method === "item/reasoning/summaryTextDelta" || method === "item/reasoning/textDelta") return "turn/completed";
|
||||
if (method === "error") return "turn/completed";
|
||||
if (method === "turn/completed") return "turn/completed";
|
||||
return "app-server-notification";
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,7 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
|
||||
RUNTIME_STORE_KIND_POSTGRES
|
||||
} from "../db/runtime-store.mjs";
|
||||
} from "../db/runtime-store.ts";
|
||||
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
||||
|
||||
export const DEV_DB_ENV_CONTRACT = Object.freeze({
|
||||
@@ -1,14 +1,14 @@
|
||||
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
|
||||
import { describeCodeAgentAvailability } from "./code-agent-chat.mjs";
|
||||
import { describeCodeAgentAvailability } from "./code-agent-chat.ts";
|
||||
import {
|
||||
applyRuntimeDbReadinessLayers,
|
||||
buildDbRuntimeReadiness
|
||||
} from "./db-contract.mjs";
|
||||
import { buildCloudApiReadiness } from "./health-contract.mjs";
|
||||
} from "./db-contract.ts";
|
||||
import { buildCloudApiReadiness } from "./health-contract.ts";
|
||||
import {
|
||||
M3_IO_CONTROL_ROUTE,
|
||||
describeM3IoControlLive
|
||||
} from "./m3-io-control.mjs";
|
||||
} from "./m3-io-control.ts";
|
||||
|
||||
const STATUS = Object.freeze({
|
||||
pass: "通过",
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_UNCONFIGURED,
|
||||
RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
|
||||
RUNTIME_STORE_KIND_POSTGRES
|
||||
} from "../db/runtime-store.mjs";
|
||||
} from "../db/runtime-store.ts";
|
||||
|
||||
export const CLOUD_API_HEALTH_CONTRACT_VERSION = "v3";
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { AUDIT_FIELD_NAMES } from "../audit/index.mjs";
|
||||
import { ERROR_CODES, validateResponse } from "../protocol/index.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import { handleJsonRpcRequest } from "./json-rpc.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.ts";
|
||||
import { handleJsonRpcRequest } from "./json-rpc.ts";
|
||||
|
||||
test("unknown JSON-RPC method returns an error envelope with audit fields", async () => {
|
||||
const response = await handleJsonRpcRequest({
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
createAuditRecord,
|
||||
deriveActorFromMeta
|
||||
} from "../audit/index.mjs";
|
||||
import { buildDbRuntimeReadiness } from "./db-contract.mjs";
|
||||
import { describeCodeAgentAvailability } from "./code-agent-chat.mjs";
|
||||
import { buildCloudApiReadiness } from "./health-contract.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import { M3_IO_RPC_METHODS, handleM3IoRpc } from "./m3-io-control.mjs";
|
||||
import { createGatewayShellRequest } from "./gateway-demo-registry.mjs";
|
||||
import { buildDbRuntimeReadiness } from "./db-contract.ts";
|
||||
import { describeCodeAgentAvailability } from "./code-agent-chat.ts";
|
||||
import { buildCloudApiReadiness } from "./health-contract.ts";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.ts";
|
||||
import { M3_IO_RPC_METHODS, handleM3IoRpc } from "./m3-io-control.ts";
|
||||
import { createGatewayShellRequest } from "./gateway-demo-registry.ts";
|
||||
|
||||
export const SUPPORTED_RPC_METHODS = Object.freeze([
|
||||
"system.health",
|
||||
@@ -0,0 +1,771 @@
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
CLOUD_API_SERVICE_ID,
|
||||
createProtocolAuditEvent
|
||||
} from "../audit/index.mjs";
|
||||
import { ENVIRONMENT_DEV, assertProtocolRecord } from "../protocol/index.mjs";
|
||||
import { HWLAB_M3_IO_CAPABILITY_LEVELS } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 2200;
|
||||
const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1";
|
||||
const M3_IO_CONTROL_ROUTE = "/v1/m3/io";
|
||||
const M3_IO_CHAIN = Object.freeze({
|
||||
projectId: "prj_m3_hardware_loop",
|
||||
sourceGatewayId: "gwsimu_1",
|
||||
sourceGatewaySessionId: "gws_gwsimu_1",
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourceBoxId: "boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
targetGatewayId: "gwsimu_2",
|
||||
targetGatewaySessionId: "gws_gwsimu_2",
|
||||
targetResourceId: "res_boxsimu_2",
|
||||
targetBoxId: "boxsimu_2",
|
||||
targetPort: "DI1",
|
||||
patchPanelServiceId: "hwlab-patch-panel"
|
||||
});
|
||||
const M3_IO_BLOCKER_CODES = Object.freeze({
|
||||
gatewayUnavailable: "m3_gateway_session_unavailable",
|
||||
boxUnavailable: "m3_box_resource_unavailable",
|
||||
portDirectionInvalid: "m3_port_direction_invalid",
|
||||
wiringMissing: "m3_wiring_missing",
|
||||
patchPanelUnavailable: "m3_patch_panel_unavailable",
|
||||
dispatchFailed: "m3_gateway_dispatch_failed",
|
||||
runtimeDurableBlocked: "runtime_durable_not_green"
|
||||
});
|
||||
|
||||
export async function requestRuntimeJson(url, { method = "GET", body, requestJson, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
||||
if (requestJson) {
|
||||
try {
|
||||
const result = await requestJson(url, { method, body, timeoutMs });
|
||||
return normalizeRuntimeJsonResponse(result);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: body === undefined
|
||||
? { accept: "application/json" }
|
||||
: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: parsed,
|
||||
error: response.ok ? null : parsed?.error?.message ?? parsed?.message ?? response.statusText
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: error.name === "AbortError" ? `request timed out after ${timeoutMs}ms` : error.message
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeRuntimeJsonResponse(result = {}) {
|
||||
if (Object.hasOwn(result, "ok") || Object.hasOwn(result, "body")) {
|
||||
return {
|
||||
ok: result.ok ?? (Number(result.status ?? 200) >= 200 && Number(result.status ?? 200) < 300),
|
||||
status: result.status ?? 200,
|
||||
body: result.body ?? result.json ?? null,
|
||||
error: result.error ?? result.body?.error?.message ?? null
|
||||
};
|
||||
}
|
||||
if (Object.hasOwn(result, "status") || Object.hasOwn(result, "json")) {
|
||||
const status = result.status ?? 200;
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
body: result.json ?? null,
|
||||
error: result.error ?? null
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: result
|
||||
};
|
||||
}
|
||||
|
||||
export async function persistRuntimeRegistrations({ runtimeStore, gatewayStatus, box, capability, command, meta }) {
|
||||
const gatewaySessionId = gatewayStatus.session?.gatewaySessionId ?? gatewayStatus.gatewaySessionId ?? gatewayStatus.registry?.gatewaySessionId ?? command.gatewaySessionId;
|
||||
const gatewayId = gatewayStatus.gatewayId ?? gatewayStatus.session?.gatewayId ?? gatewayStatus.registry?.gatewayId ?? command.gatewayId;
|
||||
const gatewaySession = {
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
gatewaySessionId,
|
||||
gatewayId,
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
endpoint: gatewayStatus.registry?.endpoint ?? gatewayStatus.session?.endpoint,
|
||||
status: "connected",
|
||||
labels: {
|
||||
m3ControlSurface: "true"
|
||||
}
|
||||
};
|
||||
const resource = {
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
gatewaySessionId,
|
||||
resourceId: command.resourceId,
|
||||
boxId: box.boxId ?? command.boxId,
|
||||
resourceType: "simulator_endpoint",
|
||||
state: "available",
|
||||
metadata: {
|
||||
serviceId: "hwlab-box-simu",
|
||||
patchPanelOnlyPropagation: true,
|
||||
frontendBypassAllowed: false
|
||||
}
|
||||
};
|
||||
const writes = [];
|
||||
writes.push(await tryRuntimeWrite("gateway.session.register", () => runtimeStore?.registerGatewaySession?.(gatewaySession, meta)));
|
||||
writes.push(await tryRuntimeWrite("box.resource.register", () => runtimeStore?.registerBoxResource?.(resource, meta)));
|
||||
writes.push(await tryRuntimeWrite("box.capability.report", () => runtimeStore?.reportBoxCapabilities?.({ capability }, meta)));
|
||||
return writes;
|
||||
}
|
||||
|
||||
export async function tryRuntimeWrite(label, write) {
|
||||
if (typeof write !== "function") {
|
||||
return {
|
||||
label,
|
||||
written: false,
|
||||
skipped: true,
|
||||
reason: "runtimeStore method unavailable"
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await write();
|
||||
if (result === undefined) {
|
||||
return {
|
||||
label,
|
||||
written: false,
|
||||
skipped: true,
|
||||
reason: "runtimeStore method unavailable"
|
||||
};
|
||||
}
|
||||
return {
|
||||
label,
|
||||
written: true,
|
||||
result
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
label,
|
||||
written: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
code: error?.code ?? null,
|
||||
data: error?.data ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function successControlResult(input) {
|
||||
const records = await writeOutcomeRecords({
|
||||
...input,
|
||||
outcome: "succeeded",
|
||||
reason: null
|
||||
});
|
||||
const target = controlTarget(input.command, input.operation.gatewaySessionId);
|
||||
const audit = auditState(records.auditWrite, records.runtimeAfter);
|
||||
const evidence = evidenceState(records.runtimeAfter, records);
|
||||
const readback = input.command.action === "di.read"
|
||||
? {
|
||||
status: "succeeded",
|
||||
value: readValueFromGatewayResult(input.dispatch.body),
|
||||
resourceId: input.command.resourceId,
|
||||
port: input.command.port
|
||||
}
|
||||
: input.targetReadback ?? null;
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready,
|
||||
controlReady: true,
|
||||
action: input.action,
|
||||
chain: M3_IO_CHAIN,
|
||||
command: input.command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: input.operation.operationId,
|
||||
traceId: input.meta.traceId,
|
||||
auditId: records.auditEvent.auditId,
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
audit: {
|
||||
auditId: records.auditEvent.auditId,
|
||||
...audit
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
...evidence
|
||||
},
|
||||
operationState: {
|
||||
status: "succeeded",
|
||||
reachable: true
|
||||
},
|
||||
auditState: audit,
|
||||
operation: {
|
||||
...input.operation,
|
||||
status: "succeeded",
|
||||
completedAt: input.now,
|
||||
updatedAt: input.now
|
||||
},
|
||||
result: {
|
||||
value: input.command.action === "di.read"
|
||||
? readValueFromGatewayResult(input.dispatch.body)
|
||||
: input.command.value,
|
||||
dispatch: gatewayResultSummary(input.gateway),
|
||||
patchPanel: input.patchPanel?.body ?? null,
|
||||
targetReadback: input.targetReadback ?? null
|
||||
},
|
||||
readback,
|
||||
controlPath: {
|
||||
status: "succeeded",
|
||||
cloudApi: true,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: input.action === "do.write",
|
||||
frontendBypass: false
|
||||
},
|
||||
auditEvent: records.auditEvent,
|
||||
evidenceRecord: records.evidenceRecord,
|
||||
evidenceState: evidence,
|
||||
durableStatus: durableStatus(records.runtimeAfter),
|
||||
trustBlocker: trustBlocker(records.runtimeAfter),
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code: records.runtimeAfter?.blocker,
|
||||
reason: records.runtimeAfter?.reason,
|
||||
runtime: records.runtimeAfter
|
||||
}),
|
||||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||||
observedAt: input.now
|
||||
};
|
||||
}
|
||||
|
||||
export async function blockedControlResult(input) {
|
||||
const records = await writeOutcomeRecords({
|
||||
...input,
|
||||
outcome: "failed",
|
||||
reason: input.reason
|
||||
});
|
||||
const target = controlTarget(input.command, input.operation.gatewaySessionId);
|
||||
const audit = auditState(records.auditWrite, records.runtimeAfter);
|
||||
const evidence = evidenceState(records.runtimeAfter, records);
|
||||
const readback = input.command.action === "di.read"
|
||||
? null
|
||||
: input.targetReadback ?? null;
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||||
controlReady: false,
|
||||
action: input.action,
|
||||
chain: M3_IO_CHAIN,
|
||||
command: input.command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: input.operation.operationId,
|
||||
traceId: input.meta.traceId,
|
||||
auditId: records.auditEvent.auditId,
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
audit: {
|
||||
auditId: records.auditEvent.auditId,
|
||||
...audit
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
...evidence
|
||||
},
|
||||
blocker: {
|
||||
code: input.code,
|
||||
message: input.reason,
|
||||
zh: input.reason
|
||||
},
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code: input.code,
|
||||
reason: input.reason,
|
||||
runtime: records.runtimeAfter
|
||||
}),
|
||||
operationState: {
|
||||
status: "blocked",
|
||||
reachable: false,
|
||||
blocker: input.code
|
||||
},
|
||||
auditState: audit,
|
||||
operation: {
|
||||
...input.operation,
|
||||
status: "failed",
|
||||
completedAt: input.now,
|
||||
updatedAt: input.now,
|
||||
error: {
|
||||
code: input.code,
|
||||
message: input.reason
|
||||
}
|
||||
},
|
||||
result: {
|
||||
dispatch: input.gateway ? gatewayResultSummary(input.gateway) : null,
|
||||
patchPanel: input.patchPanel?.body ?? null,
|
||||
targetReadback: input.targetReadback ?? null
|
||||
},
|
||||
readback,
|
||||
controlPath: {
|
||||
status: "blocked",
|
||||
cloudApi: true,
|
||||
gatewaySimu: Boolean(input.gateway),
|
||||
boxSimu: input.code !== M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||||
patchPanel: Boolean(input.patchPanel?.ok),
|
||||
frontendBypass: false
|
||||
},
|
||||
auditEvent: records.auditEvent,
|
||||
evidenceRecord: records.evidenceRecord,
|
||||
evidenceState: evidence,
|
||||
durableStatus: durableStatus(records.runtimeAfter),
|
||||
trustBlocker: trustBlocker(records.runtimeAfter),
|
||||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||||
observedAt: input.now
|
||||
};
|
||||
}
|
||||
|
||||
export function blockedResult({ action, command = null, meta, runtime, code, reason, httpStatus, now, details = {} }) {
|
||||
const target = controlTarget(command, command?.gatewaySessionId);
|
||||
const audit = {
|
||||
auditId: null,
|
||||
status: "not_written",
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: "operation was blocked before audit write"
|
||||
};
|
||||
const evidence = evidenceState(runtime, {
|
||||
auditWrite: { written: false, reason: "operation was blocked before audit write" },
|
||||
evidenceWrite: { written: false, reason: "operation was blocked before evidence write" }
|
||||
});
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||||
controlReady: false,
|
||||
action: action || "unknown",
|
||||
chain: M3_IO_CHAIN,
|
||||
command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: null,
|
||||
traceId: meta.traceId,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
audit,
|
||||
evidence: {
|
||||
evidenceId: null,
|
||||
...evidence
|
||||
},
|
||||
blocker: {
|
||||
code,
|
||||
message: reason,
|
||||
zh: reason
|
||||
},
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code,
|
||||
reason,
|
||||
runtime
|
||||
}),
|
||||
operationState: {
|
||||
status: "blocked",
|
||||
reachable: false,
|
||||
blocker: code
|
||||
},
|
||||
auditState: audit,
|
||||
result: {
|
||||
value: null,
|
||||
targetReadback: null
|
||||
},
|
||||
readback: null,
|
||||
controlPath: {
|
||||
status: "blocked",
|
||||
cloudApi: true,
|
||||
gatewaySimu: false,
|
||||
boxSimu: false,
|
||||
patchPanel: false,
|
||||
frontendBypass: false
|
||||
},
|
||||
evidenceState: evidence,
|
||||
durableStatus: durableStatus(runtime),
|
||||
trustBlocker: trustBlocker(runtime),
|
||||
persistence: {
|
||||
runtime,
|
||||
writes: []
|
||||
},
|
||||
observedAt: now,
|
||||
httpStatus,
|
||||
...details
|
||||
};
|
||||
}
|
||||
|
||||
export async function writeOutcomeRecords({
|
||||
action,
|
||||
command,
|
||||
operation,
|
||||
meta,
|
||||
runtimeStore,
|
||||
runtimeBefore,
|
||||
registration,
|
||||
operationWrite,
|
||||
tracePayload,
|
||||
outcome,
|
||||
reason,
|
||||
now
|
||||
}) {
|
||||
const auditEvent = createProtocolAuditEvent({
|
||||
auditId: `aud_${operation.operationId.slice(3)}_${outcome}`,
|
||||
traceId: meta.traceId,
|
||||
actorType: "user",
|
||||
actorId: meta.actorId,
|
||||
action: action === "do.write" ? "m3.io.do.write" : "m3.io.di.read",
|
||||
targetType: "hardware_operation",
|
||||
targetId: operation.operationId,
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
gatewaySessionId: operation.gatewaySessionId,
|
||||
operationId: operation.operationId,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
outcome,
|
||||
reason: reason ?? undefined,
|
||||
metadata: {
|
||||
route: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}->${M3_IO_CHAIN.patchPanelServiceId}->${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`,
|
||||
command,
|
||||
frontendBypass: false,
|
||||
runtimeDurableBefore: runtimeBefore?.status ?? "unknown"
|
||||
},
|
||||
occurredAt: now
|
||||
});
|
||||
const evidenceRecord = createEvidenceRecord({
|
||||
evidenceId: `evd_${operation.operationId.slice(3)}_${outcome}`,
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
operationId: operation.operationId,
|
||||
traceId: meta.traceId,
|
||||
createdAt: now,
|
||||
payload: {
|
||||
outcome,
|
||||
reason,
|
||||
tracePayload,
|
||||
command,
|
||||
frontendBypass: false,
|
||||
patchPanelOnlyPropagation: true
|
||||
}
|
||||
});
|
||||
const auditWrite = await tryRuntimeWrite("audit.event.write", () => runtimeStore?.writeAuditEvent?.({ event: auditEvent }, meta));
|
||||
const evidenceWrite = await tryRuntimeWrite("evidence.record.write", () => runtimeStore?.writeEvidenceRecord?.({ record: evidenceRecord }, meta));
|
||||
const runtimeAfter = await safeRuntimeSummary(runtimeStore);
|
||||
return {
|
||||
auditEvent,
|
||||
evidenceRecord,
|
||||
auditWrite,
|
||||
evidenceWrite,
|
||||
runtimeAfter,
|
||||
registration,
|
||||
operationWrite
|
||||
};
|
||||
}
|
||||
|
||||
export function createEvidenceRecord({ evidenceId, projectId, operationId, traceId, payload, createdAt }) {
|
||||
const metadata = {
|
||||
traceId,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
route: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}->${M3_IO_CHAIN.patchPanelServiceId}->${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`,
|
||||
frontendBypass: false,
|
||||
patchPanelOnlyPropagation: true
|
||||
};
|
||||
const serialized = JSON.stringify({
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
projectId,
|
||||
operationId,
|
||||
traceId,
|
||||
payload,
|
||||
metadata,
|
||||
createdAt
|
||||
});
|
||||
const record = {
|
||||
evidenceId,
|
||||
projectId,
|
||||
operationId,
|
||||
kind: "trace",
|
||||
uri: `memory://hwlab-cloud-api/${operationId}/m3-io-control.json`,
|
||||
mimeType: "application/json",
|
||||
sha256: createHash("sha256").update(serialized).digest("hex"),
|
||||
sizeBytes: Buffer.byteLength(serialized),
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
metadata,
|
||||
createdAt
|
||||
};
|
||||
assertProtocolRecord("evidenceRecord", record);
|
||||
return record;
|
||||
}
|
||||
|
||||
export function evidenceState(runtime, records) {
|
||||
const durableReady = isDurableRuntimeReady(runtime);
|
||||
const recordsWritten = records.auditWrite?.written === true && records.evidenceWrite?.written === true;
|
||||
const evidenceWriteState = recordWriteState(records.evidenceWrite, runtime);
|
||||
if (durableReady && recordsWritten) {
|
||||
return {
|
||||
status: "green",
|
||||
sourceKind: "DEV-LIVE",
|
||||
durable: true,
|
||||
writeStatus: evidenceWriteState.status,
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: "operation/audit/evidence 已通过 durable runtime 写入;仍需 DEV 浏览器 E2E 才能宣称验收通过。"
|
||||
};
|
||||
}
|
||||
const blocker = runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked;
|
||||
return {
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
durable: runtime?.durable === true,
|
||||
blocker,
|
||||
writeStatus: evidenceWriteState.status,
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: `操作链路可达性与可信持久化分离:runtime durable 未 green(status=${runtime?.status ?? "unknown"},blocker=${blocker}),不能宣称可信闭环完全 green。`
|
||||
};
|
||||
}
|
||||
|
||||
export function auditState(auditWrite, runtime) {
|
||||
return {
|
||||
...recordWriteState(auditWrite, runtime),
|
||||
durableStatus: durableStatus(runtime)
|
||||
};
|
||||
}
|
||||
|
||||
export function recordWriteState(write, runtime) {
|
||||
if (write?.written === true && isDurableRuntimeReady(runtime)) {
|
||||
return {
|
||||
status: "persisted",
|
||||
durable: true,
|
||||
sourceKind: "DEV-LIVE"
|
||||
};
|
||||
}
|
||||
if (write?.written === true) {
|
||||
return {
|
||||
status: "written_non_durable",
|
||||
durable: false,
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "not_written",
|
||||
durable: false,
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||||
reason: write?.reason ?? "runtime write did not complete"
|
||||
};
|
||||
}
|
||||
|
||||
export function durableStatus(runtime) {
|
||||
const ready = isDurableRuntimeReady(runtime);
|
||||
return {
|
||||
status: runtime?.status ?? "unknown",
|
||||
durable: runtime?.durable === true,
|
||||
ready: runtime?.ready === true,
|
||||
liveRuntimeEvidence: runtime?.liveRuntimeEvidence === true,
|
||||
blocker: ready ? null : runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||||
redacted: true
|
||||
};
|
||||
}
|
||||
|
||||
export function trustBlocker(runtime) {
|
||||
if (isDurableRuntimeReady(runtime)) return null;
|
||||
const durable = durableStatus(runtime);
|
||||
return {
|
||||
code: durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||||
layer: "runtime-durable",
|
||||
zh: `runtime durable 未 green:${durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked}`,
|
||||
category: "runtime_durable",
|
||||
durableStatus: durable
|
||||
};
|
||||
}
|
||||
|
||||
export function redactedBlockerClassification({ code, reason, runtime } = {}) {
|
||||
const blockerCode = code ?? runtime?.blocker ?? null;
|
||||
if (!blockerCode) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: blockerCode,
|
||||
category: blockerCategory(blockerCode),
|
||||
runtimeStatus: runtime?.status ?? "unknown",
|
||||
redacted: true,
|
||||
message: reason ? redactBlockerMessage(reason) : null
|
||||
};
|
||||
}
|
||||
|
||||
export function blockerCategory(code) {
|
||||
if (String(code).startsWith("runtime_durable")) return "runtime_durable";
|
||||
if (String(code).includes("gateway")) return "gateway_session";
|
||||
if (String(code).includes("patch_panel")) return "patch_panel";
|
||||
if (String(code).includes("wiring")) return "patch_panel_wiring";
|
||||
if (String(code).includes("box")) return "box_resource";
|
||||
if (String(code).includes("port")) return "port_direction";
|
||||
return "m3_io_control";
|
||||
}
|
||||
|
||||
export function redactBlockerMessage(message) {
|
||||
return String(message)
|
||||
.replace(/https?:\/\/[^\s,,;]+/giu, "[redacted-url]")
|
||||
.replace(/postgres(?:ql)?:\/\/[^\s,,;]+/giu, "[redacted-db-url]");
|
||||
}
|
||||
|
||||
export function controlTarget(command, gatewaySessionId) {
|
||||
return {
|
||||
gatewayId: command?.gatewayId ?? null,
|
||||
gatewaySessionId: gatewaySessionId ?? command?.gatewaySessionId ?? null,
|
||||
resourceId: command?.resourceId ?? null,
|
||||
boxId: command?.boxId ?? null,
|
||||
port: command?.port ?? null,
|
||||
value: Object.hasOwn(command ?? {}, "value") ? command.value : null
|
||||
};
|
||||
}
|
||||
|
||||
export function persistenceSummary(runtime, registration, operationWrite, records) {
|
||||
return {
|
||||
runtime,
|
||||
writes: [
|
||||
...(registration ?? []),
|
||||
operationWrite,
|
||||
records.auditWrite,
|
||||
records.evidenceWrite
|
||||
].filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
export async function safeRuntimeSummary(runtimeStore) {
|
||||
try {
|
||||
if (typeof runtimeStore?.readiness === "function") {
|
||||
return await runtimeStore.readiness();
|
||||
}
|
||||
if (typeof runtimeStore?.summary === "function") {
|
||||
return runtimeStore.summary();
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
adapter: "unknown",
|
||||
durable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker: "runtime_summary_unavailable",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
liveRuntimeEvidence: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
adapter: "none",
|
||||
durable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker: "runtime_store_missing",
|
||||
reason: "runtime store is not available",
|
||||
liveRuntimeEvidence: false
|
||||
};
|
||||
}
|
||||
|
||||
export function isDurableRuntimeReady(runtime) {
|
||||
if (!runtime || typeof runtime !== "object") return false;
|
||||
if (runtime.durable !== true) return false;
|
||||
if (runtime.ready === false) return false;
|
||||
if (runtime.liveRuntimeEvidence !== true) return false;
|
||||
return !["blocked", "degraded", "failed"].includes(String(runtime.status ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function readValueFromGatewayResult(result) {
|
||||
return result?.result?.value ?? result?.value ?? null;
|
||||
}
|
||||
|
||||
export function gatewayResultSummary(gateway) {
|
||||
return {
|
||||
gatewayId: gateway.gatewayId,
|
||||
gatewaySessionId: gateway.gatewaySessionId,
|
||||
url: redactUrl(gateway.url),
|
||||
role: gateway.role
|
||||
};
|
||||
}
|
||||
|
||||
export function publicGatewayStatus(status) {
|
||||
return {
|
||||
gatewayId: status?.gatewayId ?? status?.session?.gatewayId ?? null,
|
||||
gatewaySessionId: status?.session?.gatewaySessionId ?? status?.gatewaySessionId ?? null,
|
||||
boxCount: Array.isArray(status?.registry?.boxes) ? status.registry.boxes.length : null
|
||||
};
|
||||
}
|
||||
|
||||
export function finalizeControlResult(result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function ensureTrailingSlash(url) {
|
||||
return url.endsWith("/") ? url : `${url}/`;
|
||||
}
|
||||
|
||||
export function trimUrl(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
export function parseTimeout(value) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
export function redactUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.username = parsed.username ? "***" : "";
|
||||
parsed.password = parsed.password ? "***" : "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return "invalid-url";
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.ts";
|
||||
import { createBoxState, createGatewayState } from "../sim/model.mjs";
|
||||
import { writeBoxPort } from "../sim/l2-runtime.mjs";
|
||||
import { validateResponse } from "../protocol/index.mjs";
|
||||
import { handleJsonRpcRequest, SUPPORTED_RPC_METHODS } from "./json-rpc.mjs";
|
||||
import { handleJsonRpcRequest, SUPPORTED_RPC_METHODS } from "./json-rpc.ts";
|
||||
import { HWLAB_M3_IO_CAPABILITY_LEVELS } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
||||
import {
|
||||
M3_IO_BLOCKER_CODES,
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
describeM3IoControlLive,
|
||||
describeM3StatusLive,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.mjs";
|
||||
} from "./m3-io-control.ts";
|
||||
|
||||
const fixedNow = "2026-05-23T00:00:00.000Z";
|
||||
|
||||
@@ -10,6 +10,37 @@ import {
|
||||
CLOUD_API_SERVICE_ID,
|
||||
createProtocolAuditEvent
|
||||
} from "../audit/index.mjs";
|
||||
import {
|
||||
requestRuntimeJson,
|
||||
normalizeRuntimeJsonResponse,
|
||||
persistRuntimeRegistrations,
|
||||
tryRuntimeWrite,
|
||||
successControlResult,
|
||||
blockedControlResult,
|
||||
blockedResult,
|
||||
writeOutcomeRecords,
|
||||
createEvidenceRecord,
|
||||
evidenceState,
|
||||
auditState,
|
||||
recordWriteState,
|
||||
durableStatus,
|
||||
trustBlocker,
|
||||
redactedBlockerClassification,
|
||||
blockerCategory,
|
||||
redactBlockerMessage,
|
||||
controlTarget,
|
||||
persistenceSummary,
|
||||
safeRuntimeSummary,
|
||||
isDurableRuntimeReady,
|
||||
readValueFromGatewayResult,
|
||||
gatewayResultSummary,
|
||||
publicGatewayStatus,
|
||||
finalizeControlResult,
|
||||
ensureTrailingSlash,
|
||||
trimUrl,
|
||||
parseTimeout,
|
||||
redactUrl
|
||||
} from "./m3-io-control-runtime.ts";
|
||||
import { HWLAB_M3_IO_CAPABILITY_LEVELS } from "../../skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs";
|
||||
|
||||
export const M3_IO_CONTROL_CONTRACT_VERSION = "m3-io-control-v1";
|
||||
@@ -46,7 +77,7 @@ export const M3_IO_BLOCKER_CODES = Object.freeze({
|
||||
runtimeDurableBlocked: "runtime_durable_not_green"
|
||||
});
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 2200;
|
||||
export const DEFAULT_TIMEOUT_MS = 2200;
|
||||
const DEV_SERVICE_ENDPOINTS = Object.freeze({
|
||||
gateway1: "http://hwlab-gateway-simu-1.hwlab-dev.svc.cluster.local:7101",
|
||||
gateway2: "http://hwlab-gateway-simu-2.hwlab-dev.svc.cluster.local:7101",
|
||||
@@ -1704,738 +1735,3 @@ function firstDiagnostic(body) {
|
||||
...(Array.isArray(body?.routes) ? body.routes.flatMap((route) => route.diagnostics ?? []) : [])
|
||||
][0] ?? null;
|
||||
}
|
||||
|
||||
async function requestRuntimeJson(url, { method = "GET", body, requestJson, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
||||
if (requestJson) {
|
||||
try {
|
||||
const result = await requestJson(url, { method, body, timeoutMs });
|
||||
return normalizeRuntimeJsonResponse(result);
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: body === undefined
|
||||
? { accept: "application/json" }
|
||||
: {
|
||||
accept: "application/json",
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
body: parsed,
|
||||
error: response.ok ? null : parsed?.error?.message ?? parsed?.message ?? response.statusText
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 0,
|
||||
error: error.name === "AbortError" ? `request timed out after ${timeoutMs}ms` : error.message
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeRuntimeJsonResponse(result = {}) {
|
||||
if (Object.hasOwn(result, "ok") || Object.hasOwn(result, "body")) {
|
||||
return {
|
||||
ok: result.ok ?? (Number(result.status ?? 200) >= 200 && Number(result.status ?? 200) < 300),
|
||||
status: result.status ?? 200,
|
||||
body: result.body ?? result.json ?? null,
|
||||
error: result.error ?? result.body?.error?.message ?? null
|
||||
};
|
||||
}
|
||||
if (Object.hasOwn(result, "status") || Object.hasOwn(result, "json")) {
|
||||
const status = result.status ?? 200;
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
body: result.json ?? null,
|
||||
error: result.error ?? null
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: result
|
||||
};
|
||||
}
|
||||
|
||||
async function persistRuntimeRegistrations({ runtimeStore, gatewayStatus, box, capability, command, meta }) {
|
||||
const gatewaySessionId = gatewayStatus.session?.gatewaySessionId ?? gatewayStatus.gatewaySessionId ?? gatewayStatus.registry?.gatewaySessionId ?? command.gatewaySessionId;
|
||||
const gatewayId = gatewayStatus.gatewayId ?? gatewayStatus.session?.gatewayId ?? gatewayStatus.registry?.gatewayId ?? command.gatewayId;
|
||||
const gatewaySession = {
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
gatewaySessionId,
|
||||
gatewayId,
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
endpoint: gatewayStatus.registry?.endpoint ?? gatewayStatus.session?.endpoint,
|
||||
status: "connected",
|
||||
labels: {
|
||||
m3ControlSurface: "true"
|
||||
}
|
||||
};
|
||||
const resource = {
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
gatewaySessionId,
|
||||
resourceId: command.resourceId,
|
||||
boxId: box.boxId ?? command.boxId,
|
||||
resourceType: "simulator_endpoint",
|
||||
state: "available",
|
||||
metadata: {
|
||||
serviceId: "hwlab-box-simu",
|
||||
patchPanelOnlyPropagation: true,
|
||||
frontendBypassAllowed: false
|
||||
}
|
||||
};
|
||||
const writes = [];
|
||||
writes.push(await tryRuntimeWrite("gateway.session.register", () => runtimeStore?.registerGatewaySession?.(gatewaySession, meta)));
|
||||
writes.push(await tryRuntimeWrite("box.resource.register", () => runtimeStore?.registerBoxResource?.(resource, meta)));
|
||||
writes.push(await tryRuntimeWrite("box.capability.report", () => runtimeStore?.reportBoxCapabilities?.({ capability }, meta)));
|
||||
return writes;
|
||||
}
|
||||
|
||||
async function tryRuntimeWrite(label, write) {
|
||||
if (typeof write !== "function") {
|
||||
return {
|
||||
label,
|
||||
written: false,
|
||||
skipped: true,
|
||||
reason: "runtimeStore method unavailable"
|
||||
};
|
||||
}
|
||||
try {
|
||||
const result = await write();
|
||||
if (result === undefined) {
|
||||
return {
|
||||
label,
|
||||
written: false,
|
||||
skipped: true,
|
||||
reason: "runtimeStore method unavailable"
|
||||
};
|
||||
}
|
||||
return {
|
||||
label,
|
||||
written: true,
|
||||
result
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
label,
|
||||
written: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
code: error?.code ?? null,
|
||||
data: error?.data ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function successControlResult(input) {
|
||||
const records = await writeOutcomeRecords({
|
||||
...input,
|
||||
outcome: "succeeded",
|
||||
reason: null
|
||||
});
|
||||
const target = controlTarget(input.command, input.operation.gatewaySessionId);
|
||||
const audit = auditState(records.auditWrite, records.runtimeAfter);
|
||||
const evidence = evidenceState(records.runtimeAfter, records);
|
||||
const readback = input.command.action === "di.read"
|
||||
? {
|
||||
status: "succeeded",
|
||||
value: readValueFromGatewayResult(input.dispatch.body),
|
||||
resourceId: input.command.resourceId,
|
||||
port: input.command.port
|
||||
}
|
||||
: input.targetReadback ?? null;
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "succeeded",
|
||||
accepted: true,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.ready,
|
||||
controlReady: true,
|
||||
action: input.action,
|
||||
chain: M3_IO_CHAIN,
|
||||
command: input.command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: input.operation.operationId,
|
||||
traceId: input.meta.traceId,
|
||||
auditId: records.auditEvent.auditId,
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
audit: {
|
||||
auditId: records.auditEvent.auditId,
|
||||
...audit
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
...evidence
|
||||
},
|
||||
operationState: {
|
||||
status: "succeeded",
|
||||
reachable: true
|
||||
},
|
||||
auditState: audit,
|
||||
operation: {
|
||||
...input.operation,
|
||||
status: "succeeded",
|
||||
completedAt: input.now,
|
||||
updatedAt: input.now
|
||||
},
|
||||
result: {
|
||||
value: input.command.action === "di.read"
|
||||
? readValueFromGatewayResult(input.dispatch.body)
|
||||
: input.command.value,
|
||||
dispatch: gatewayResultSummary(input.gateway),
|
||||
patchPanel: input.patchPanel?.body ?? null,
|
||||
targetReadback: input.targetReadback ?? null
|
||||
},
|
||||
readback,
|
||||
controlPath: {
|
||||
status: "succeeded",
|
||||
cloudApi: true,
|
||||
gatewaySimu: true,
|
||||
boxSimu: true,
|
||||
patchPanel: input.action === "do.write",
|
||||
frontendBypass: false
|
||||
},
|
||||
auditEvent: records.auditEvent,
|
||||
evidenceRecord: records.evidenceRecord,
|
||||
evidenceState: evidence,
|
||||
durableStatus: durableStatus(records.runtimeAfter),
|
||||
trustBlocker: trustBlocker(records.runtimeAfter),
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code: records.runtimeAfter?.blocker,
|
||||
reason: records.runtimeAfter?.reason,
|
||||
runtime: records.runtimeAfter
|
||||
}),
|
||||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||||
observedAt: input.now
|
||||
};
|
||||
}
|
||||
|
||||
async function blockedControlResult(input) {
|
||||
const records = await writeOutcomeRecords({
|
||||
...input,
|
||||
outcome: "failed",
|
||||
reason: input.reason
|
||||
});
|
||||
const target = controlTarget(input.command, input.operation.gatewaySessionId);
|
||||
const audit = auditState(records.auditWrite, records.runtimeAfter);
|
||||
const evidence = evidenceState(records.runtimeAfter, records);
|
||||
const readback = input.command.action === "di.read"
|
||||
? null
|
||||
: input.targetReadback ?? null;
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||||
controlReady: false,
|
||||
action: input.action,
|
||||
chain: M3_IO_CHAIN,
|
||||
command: input.command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: input.operation.operationId,
|
||||
traceId: input.meta.traceId,
|
||||
auditId: records.auditEvent.auditId,
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
audit: {
|
||||
auditId: records.auditEvent.auditId,
|
||||
...audit
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: records.evidenceRecord.evidenceId,
|
||||
...evidence
|
||||
},
|
||||
blocker: {
|
||||
code: input.code,
|
||||
message: input.reason,
|
||||
zh: input.reason
|
||||
},
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code: input.code,
|
||||
reason: input.reason,
|
||||
runtime: records.runtimeAfter
|
||||
}),
|
||||
operationState: {
|
||||
status: "blocked",
|
||||
reachable: false,
|
||||
blocker: input.code
|
||||
},
|
||||
auditState: audit,
|
||||
operation: {
|
||||
...input.operation,
|
||||
status: "failed",
|
||||
completedAt: input.now,
|
||||
updatedAt: input.now,
|
||||
error: {
|
||||
code: input.code,
|
||||
message: input.reason
|
||||
}
|
||||
},
|
||||
result: {
|
||||
dispatch: input.gateway ? gatewayResultSummary(input.gateway) : null,
|
||||
patchPanel: input.patchPanel?.body ?? null,
|
||||
targetReadback: input.targetReadback ?? null
|
||||
},
|
||||
readback,
|
||||
controlPath: {
|
||||
status: "blocked",
|
||||
cloudApi: true,
|
||||
gatewaySimu: Boolean(input.gateway),
|
||||
boxSimu: input.code !== M3_IO_BLOCKER_CODES.gatewayUnavailable,
|
||||
patchPanel: Boolean(input.patchPanel?.ok),
|
||||
frontendBypass: false
|
||||
},
|
||||
auditEvent: records.auditEvent,
|
||||
evidenceRecord: records.evidenceRecord,
|
||||
evidenceState: evidence,
|
||||
durableStatus: durableStatus(records.runtimeAfter),
|
||||
trustBlocker: trustBlocker(records.runtimeAfter),
|
||||
persistence: persistenceSummary(records.runtimeAfter, input.registration, input.operationWrite, records),
|
||||
observedAt: input.now
|
||||
};
|
||||
}
|
||||
|
||||
function blockedResult({ action, command = null, meta, runtime, code, reason, httpStatus, now, details = {} }) {
|
||||
const target = controlTarget(command, command?.gatewaySessionId);
|
||||
const audit = {
|
||||
auditId: null,
|
||||
status: "not_written",
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: "operation was blocked before audit write"
|
||||
};
|
||||
const evidence = evidenceState(runtime, {
|
||||
auditWrite: { written: false, reason: "operation was blocked before audit write" },
|
||||
evidenceWrite: { written: false, reason: "operation was blocked before evidence write" }
|
||||
});
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked,
|
||||
controlReady: false,
|
||||
action: action || "unknown",
|
||||
chain: M3_IO_CHAIN,
|
||||
command,
|
||||
target,
|
||||
gatewayId: target.gatewayId,
|
||||
gatewaySessionId: target.gatewaySessionId,
|
||||
resourceId: target.resourceId,
|
||||
boxId: target.boxId,
|
||||
port: target.port,
|
||||
value: target.value,
|
||||
operationId: null,
|
||||
traceId: meta.traceId,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
audit,
|
||||
evidence: {
|
||||
evidenceId: null,
|
||||
...evidence
|
||||
},
|
||||
blocker: {
|
||||
code,
|
||||
message: reason,
|
||||
zh: reason
|
||||
},
|
||||
blockerClassification: redactedBlockerClassification({
|
||||
code,
|
||||
reason,
|
||||
runtime
|
||||
}),
|
||||
operationState: {
|
||||
status: "blocked",
|
||||
reachable: false,
|
||||
blocker: code
|
||||
},
|
||||
auditState: audit,
|
||||
result: {
|
||||
value: null,
|
||||
targetReadback: null
|
||||
},
|
||||
readback: null,
|
||||
controlPath: {
|
||||
status: "blocked",
|
||||
cloudApi: true,
|
||||
gatewaySimu: false,
|
||||
boxSimu: false,
|
||||
patchPanel: false,
|
||||
frontendBypass: false
|
||||
},
|
||||
evidenceState: evidence,
|
||||
durableStatus: durableStatus(runtime),
|
||||
trustBlocker: trustBlocker(runtime),
|
||||
persistence: {
|
||||
runtime,
|
||||
writes: []
|
||||
},
|
||||
observedAt: now,
|
||||
httpStatus,
|
||||
...details
|
||||
};
|
||||
}
|
||||
|
||||
async function writeOutcomeRecords({
|
||||
action,
|
||||
command,
|
||||
operation,
|
||||
meta,
|
||||
runtimeStore,
|
||||
runtimeBefore,
|
||||
registration,
|
||||
operationWrite,
|
||||
tracePayload,
|
||||
outcome,
|
||||
reason,
|
||||
now
|
||||
}) {
|
||||
const auditEvent = createProtocolAuditEvent({
|
||||
auditId: `aud_${operation.operationId.slice(3)}_${outcome}`,
|
||||
traceId: meta.traceId,
|
||||
actorType: "user",
|
||||
actorId: meta.actorId,
|
||||
action: action === "do.write" ? "m3.io.do.write" : "m3.io.di.read",
|
||||
targetType: "hardware_operation",
|
||||
targetId: operation.operationId,
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
gatewaySessionId: operation.gatewaySessionId,
|
||||
operationId: operation.operationId,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
outcome,
|
||||
reason: reason ?? undefined,
|
||||
metadata: {
|
||||
route: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}->${M3_IO_CHAIN.patchPanelServiceId}->${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`,
|
||||
command,
|
||||
frontendBypass: false,
|
||||
runtimeDurableBefore: runtimeBefore?.status ?? "unknown"
|
||||
},
|
||||
occurredAt: now
|
||||
});
|
||||
const evidenceRecord = createEvidenceRecord({
|
||||
evidenceId: `evd_${operation.operationId.slice(3)}_${outcome}`,
|
||||
projectId: M3_IO_CHAIN.projectId,
|
||||
operationId: operation.operationId,
|
||||
traceId: meta.traceId,
|
||||
createdAt: now,
|
||||
payload: {
|
||||
outcome,
|
||||
reason,
|
||||
tracePayload,
|
||||
command,
|
||||
frontendBypass: false,
|
||||
patchPanelOnlyPropagation: true
|
||||
}
|
||||
});
|
||||
const auditWrite = await tryRuntimeWrite("audit.event.write", () => runtimeStore?.writeAuditEvent?.({ event: auditEvent }, meta));
|
||||
const evidenceWrite = await tryRuntimeWrite("evidence.record.write", () => runtimeStore?.writeEvidenceRecord?.({ record: evidenceRecord }, meta));
|
||||
const runtimeAfter = await safeRuntimeSummary(runtimeStore);
|
||||
return {
|
||||
auditEvent,
|
||||
evidenceRecord,
|
||||
auditWrite,
|
||||
evidenceWrite,
|
||||
runtimeAfter,
|
||||
registration,
|
||||
operationWrite
|
||||
};
|
||||
}
|
||||
|
||||
function createEvidenceRecord({ evidenceId, projectId, operationId, traceId, payload, createdAt }) {
|
||||
const metadata = {
|
||||
traceId,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
route: `${M3_IO_CHAIN.sourceResourceId}:${M3_IO_CHAIN.sourcePort}->${M3_IO_CHAIN.patchPanelServiceId}->${M3_IO_CHAIN.targetResourceId}:${M3_IO_CHAIN.targetPort}`,
|
||||
frontendBypass: false,
|
||||
patchPanelOnlyPropagation: true
|
||||
};
|
||||
const serialized = JSON.stringify({
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
projectId,
|
||||
operationId,
|
||||
traceId,
|
||||
payload,
|
||||
metadata,
|
||||
createdAt
|
||||
});
|
||||
const record = {
|
||||
evidenceId,
|
||||
projectId,
|
||||
operationId,
|
||||
kind: "trace",
|
||||
uri: `memory://hwlab-cloud-api/${operationId}/m3-io-control.json`,
|
||||
mimeType: "application/json",
|
||||
sha256: createHash("sha256").update(serialized).digest("hex"),
|
||||
sizeBytes: Buffer.byteLength(serialized),
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
metadata,
|
||||
createdAt
|
||||
};
|
||||
assertProtocolRecord("evidenceRecord", record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function evidenceState(runtime, records) {
|
||||
const durableReady = isDurableRuntimeReady(runtime);
|
||||
const recordsWritten = records.auditWrite?.written === true && records.evidenceWrite?.written === true;
|
||||
const evidenceWriteState = recordWriteState(records.evidenceWrite, runtime);
|
||||
if (durableReady && recordsWritten) {
|
||||
return {
|
||||
status: "green",
|
||||
sourceKind: "DEV-LIVE",
|
||||
durable: true,
|
||||
writeStatus: evidenceWriteState.status,
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: "operation/audit/evidence 已通过 durable runtime 写入;仍需 DEV 浏览器 E2E 才能宣称验收通过。"
|
||||
};
|
||||
}
|
||||
const blocker = runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked;
|
||||
return {
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED",
|
||||
durable: runtime?.durable === true,
|
||||
blocker,
|
||||
writeStatus: evidenceWriteState.status,
|
||||
durableStatus: durableStatus(runtime),
|
||||
reason: `操作链路可达性与可信持久化分离:runtime durable 未 green(status=${runtime?.status ?? "unknown"},blocker=${blocker}),不能宣称可信闭环完全 green。`
|
||||
};
|
||||
}
|
||||
|
||||
function auditState(auditWrite, runtime) {
|
||||
return {
|
||||
...recordWriteState(auditWrite, runtime),
|
||||
durableStatus: durableStatus(runtime)
|
||||
};
|
||||
}
|
||||
|
||||
function recordWriteState(write, runtime) {
|
||||
if (write?.written === true && isDurableRuntimeReady(runtime)) {
|
||||
return {
|
||||
status: "persisted",
|
||||
durable: true,
|
||||
sourceKind: "DEV-LIVE"
|
||||
};
|
||||
}
|
||||
if (write?.written === true) {
|
||||
return {
|
||||
status: "written_non_durable",
|
||||
durable: false,
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "not_written",
|
||||
durable: false,
|
||||
sourceKind: "BLOCKED",
|
||||
blocker: runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||||
reason: write?.reason ?? "runtime write did not complete"
|
||||
};
|
||||
}
|
||||
|
||||
function durableStatus(runtime) {
|
||||
const ready = isDurableRuntimeReady(runtime);
|
||||
return {
|
||||
status: runtime?.status ?? "unknown",
|
||||
durable: runtime?.durable === true,
|
||||
ready: runtime?.ready === true,
|
||||
liveRuntimeEvidence: runtime?.liveRuntimeEvidence === true,
|
||||
blocker: ready ? null : runtime?.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||||
redacted: true
|
||||
};
|
||||
}
|
||||
|
||||
function trustBlocker(runtime) {
|
||||
if (isDurableRuntimeReady(runtime)) return null;
|
||||
const durable = durableStatus(runtime);
|
||||
return {
|
||||
code: durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked,
|
||||
layer: "runtime-durable",
|
||||
zh: `runtime durable 未 green:${durable.blocker ?? M3_IO_BLOCKER_CODES.runtimeDurableBlocked}`,
|
||||
category: "runtime_durable",
|
||||
durableStatus: durable
|
||||
};
|
||||
}
|
||||
|
||||
function redactedBlockerClassification({ code, reason, runtime } = {}) {
|
||||
const blockerCode = code ?? runtime?.blocker ?? null;
|
||||
if (!blockerCode) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
code: blockerCode,
|
||||
category: blockerCategory(blockerCode),
|
||||
runtimeStatus: runtime?.status ?? "unknown",
|
||||
redacted: true,
|
||||
message: reason ? redactBlockerMessage(reason) : null
|
||||
};
|
||||
}
|
||||
|
||||
function blockerCategory(code) {
|
||||
if (String(code).startsWith("runtime_durable")) return "runtime_durable";
|
||||
if (String(code).includes("gateway")) return "gateway_session";
|
||||
if (String(code).includes("patch_panel")) return "patch_panel";
|
||||
if (String(code).includes("wiring")) return "patch_panel_wiring";
|
||||
if (String(code).includes("box")) return "box_resource";
|
||||
if (String(code).includes("port")) return "port_direction";
|
||||
return "m3_io_control";
|
||||
}
|
||||
|
||||
function redactBlockerMessage(message) {
|
||||
return String(message)
|
||||
.replace(/https?:\/\/[^\s,,;]+/giu, "[redacted-url]")
|
||||
.replace(/postgres(?:ql)?:\/\/[^\s,,;]+/giu, "[redacted-db-url]");
|
||||
}
|
||||
|
||||
function controlTarget(command, gatewaySessionId) {
|
||||
return {
|
||||
gatewayId: command?.gatewayId ?? null,
|
||||
gatewaySessionId: gatewaySessionId ?? command?.gatewaySessionId ?? null,
|
||||
resourceId: command?.resourceId ?? null,
|
||||
boxId: command?.boxId ?? null,
|
||||
port: command?.port ?? null,
|
||||
value: Object.hasOwn(command ?? {}, "value") ? command.value : null
|
||||
};
|
||||
}
|
||||
|
||||
function persistenceSummary(runtime, registration, operationWrite, records) {
|
||||
return {
|
||||
runtime,
|
||||
writes: [
|
||||
...(registration ?? []),
|
||||
operationWrite,
|
||||
records.auditWrite,
|
||||
records.evidenceWrite
|
||||
].filter(Boolean)
|
||||
};
|
||||
}
|
||||
|
||||
async function safeRuntimeSummary(runtimeStore) {
|
||||
try {
|
||||
if (typeof runtimeStore?.readiness === "function") {
|
||||
return await runtimeStore.readiness();
|
||||
}
|
||||
if (typeof runtimeStore?.summary === "function") {
|
||||
return runtimeStore.summary();
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
adapter: "unknown",
|
||||
durable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker: "runtime_summary_unavailable",
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
liveRuntimeEvidence: false
|
||||
};
|
||||
}
|
||||
return {
|
||||
adapter: "none",
|
||||
durable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker: "runtime_store_missing",
|
||||
reason: "runtime store is not available",
|
||||
liveRuntimeEvidence: false
|
||||
};
|
||||
}
|
||||
|
||||
function isDurableRuntimeReady(runtime) {
|
||||
if (!runtime || typeof runtime !== "object") return false;
|
||||
if (runtime.durable !== true) return false;
|
||||
if (runtime.ready === false) return false;
|
||||
if (runtime.liveRuntimeEvidence !== true) return false;
|
||||
return !["blocked", "degraded", "failed"].includes(String(runtime.status ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
function readValueFromGatewayResult(result) {
|
||||
return result?.result?.value ?? result?.value ?? null;
|
||||
}
|
||||
|
||||
function gatewayResultSummary(gateway) {
|
||||
return {
|
||||
gatewayId: gateway.gatewayId,
|
||||
gatewaySessionId: gateway.gatewaySessionId,
|
||||
url: redactUrl(gateway.url),
|
||||
role: gateway.role
|
||||
};
|
||||
}
|
||||
|
||||
function publicGatewayStatus(status) {
|
||||
return {
|
||||
gatewayId: status?.gatewayId ?? status?.session?.gatewayId ?? null,
|
||||
gatewaySessionId: status?.session?.gatewaySessionId ?? status?.gatewaySessionId ?? null,
|
||||
boxCount: Array.isArray(status?.registry?.boxes) ? status.registry.boxes.length : null
|
||||
};
|
||||
}
|
||||
|
||||
function finalizeControlResult(result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function ensureTrailingSlash(url) {
|
||||
return url.endsWith("/") ? url : `${url}/`;
|
||||
}
|
||||
|
||||
function trimUrl(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function parseTimeout(value) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function redactUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.username = parsed.username ? "***" : "";
|
||||
parsed.password = parsed.password ? "***" : "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return "invalid-url";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,833 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer as createHttpServer } from "node:http";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.ts";
|
||||
import { validateCodeAgentChatSchema } from "./code-agent-chat.ts";
|
||||
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
|
||||
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
||||
import {
|
||||
codexStdioChatFixture,
|
||||
codexStdioReadyFixture,
|
||||
createFakeAppServerClient,
|
||||
createFakeCodexCommand,
|
||||
delay,
|
||||
pollAgentResult,
|
||||
postAgent,
|
||||
postAgentRaw,
|
||||
prepareFakeCodexHome
|
||||
} from "./server-test-helpers.ts";
|
||||
|
||||
test("cloud api /v1/agent/chat parse and params errors use structured blocker envelope", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const parse = await postAgentRaw(port, "{", { "x-trace-id": "trc_parse_error" });
|
||||
assert.equal(parse.status, 400);
|
||||
assert.equal(parse.body.status, "failed");
|
||||
assert.equal(parse.body.error.code, "parse_error");
|
||||
assert.equal(parse.body.error.layer, "api");
|
||||
assert.equal(parse.body.error.retryable, true);
|
||||
assert.equal(parse.body.error.traceId, "trc_parse_error");
|
||||
assert.equal(parse.body.error.route, "/v1/agent/chat");
|
||||
assert.match(parse.body.error.userMessage, /JSON/u);
|
||||
assert.equal(Object.hasOwn(parse.body, "reply"), false);
|
||||
assert.equal(JSON.stringify(parse.body).includes("sk-"), false);
|
||||
|
||||
const invalid = await postAgentRaw(port, JSON.stringify([]), { "x-trace-id": "trc_invalid_params" });
|
||||
assert.equal(invalid.status, 400);
|
||||
assert.equal(invalid.body.error.code, "invalid_params");
|
||||
assert.equal(invalid.body.error.layer, "api");
|
||||
assert.equal(invalid.body.error.retryable, true);
|
||||
assert.equal(invalid.body.error.blocker.code, "invalid_params");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat supports short submit and result polling", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-"));
|
||||
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-short-codex-home-"));
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||||
},
|
||||
codexStdioManager: {
|
||||
describe() {
|
||||
return {
|
||||
...codexStdioReadyFixture({ workspace, codexHome }),
|
||||
sandbox: "danger-full-access"
|
||||
};
|
||||
},
|
||||
async probe() {
|
||||
return {
|
||||
...codexStdioReadyFixture({ workspace, codexHome }),
|
||||
sandbox: "danger-full-access"
|
||||
};
|
||||
},
|
||||
async chat(params = {}) {
|
||||
return {
|
||||
...codexStdioChatFixture({ workspace, codexHome, params }),
|
||||
sandbox: "danger-full-access",
|
||||
session: {
|
||||
...codexStdioChatFixture({ workspace, codexHome, params }).session,
|
||||
sandbox: "danger-full-access"
|
||||
}
|
||||
};
|
||||
},
|
||||
cancel() {},
|
||||
reapIdle() {}
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const traceId = "trc_server-test-short-submit";
|
||||
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": traceId,
|
||||
"prefer": "respond-async",
|
||||
"x-hwlab-short-connection": "1"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-short-submit",
|
||||
message: "用pwd列出你当前的工作目录"
|
||||
})
|
||||
});
|
||||
assert.equal(submit.status, 202);
|
||||
const accepted = await submit.json();
|
||||
assert.equal(accepted.accepted, true);
|
||||
assert.equal(accepted.shortConnection, true);
|
||||
assert.equal(accepted.traceId, traceId);
|
||||
assert.equal(accepted.resultUrl, `/v1/agent/chat/result/${traceId}`);
|
||||
|
||||
const payload = await pollAgentResult(port, traceId);
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.traceId, traceId);
|
||||
assert.equal(payload.sandbox, "danger-full-access");
|
||||
assert.match(payload.reply.content, new RegExp(workspace.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")));
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat/inspect maps conversation session and thread details to trace evidence", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-"));
|
||||
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-inspect-codex-home-"));
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||||
},
|
||||
codexStdioManager: {
|
||||
describe() {
|
||||
return {
|
||||
...codexStdioReadyFixture({ workspace, codexHome }),
|
||||
sandbox: "danger-full-access",
|
||||
recentSessions: [{
|
||||
sessionId: "ses_server_stdio_pwd",
|
||||
conversationId: "cnv_server-test-inspect",
|
||||
threadId: "019e6c72-2373-75e3-9856-eff286db7163",
|
||||
status: "idle",
|
||||
currentTraceId: null,
|
||||
lastTraceId: "trc_server-test-inspect",
|
||||
workspace,
|
||||
sandbox: "danger-full-access",
|
||||
secretMaterialStored: false,
|
||||
valuesRedacted: true
|
||||
}]
|
||||
};
|
||||
},
|
||||
async probe() {
|
||||
return {
|
||||
...codexStdioReadyFixture({ workspace, codexHome }),
|
||||
sandbox: "danger-full-access"
|
||||
};
|
||||
},
|
||||
async chat(params = {}) {
|
||||
const base = codexStdioChatFixture({ workspace, codexHome, params });
|
||||
return {
|
||||
...base,
|
||||
sandbox: "danger-full-access",
|
||||
session: {
|
||||
...base.session,
|
||||
threadId: "019e6c72-2373-75e3-9856-eff286db7163",
|
||||
sandbox: "danger-full-access"
|
||||
},
|
||||
sessionReuse: {
|
||||
...base.sessionReuse,
|
||||
threadId: "019e6c72-2373-75e3-9856-eff286db7163"
|
||||
}
|
||||
};
|
||||
},
|
||||
get(sessionId) {
|
||||
if (sessionId !== "ses_server_stdio_pwd") return null;
|
||||
return {
|
||||
sessionId,
|
||||
conversationId: "cnv_server-test-inspect",
|
||||
threadId: "019e6c72-2373-75e3-9856-eff286db7163",
|
||||
status: "idle",
|
||||
lastTraceId: "trc_server-test-inspect",
|
||||
workspace,
|
||||
sandbox: "danger-full-access",
|
||||
secretMaterialStored: false,
|
||||
valuesRedacted: true
|
||||
};
|
||||
},
|
||||
cancel() {},
|
||||
reapIdle() {}
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const traceId = "trc_server-test-inspect";
|
||||
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": traceId,
|
||||
"prefer": "respond-async",
|
||||
"x-hwlab-short-connection": "1"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-inspect",
|
||||
message: "inspect mapping smoke"
|
||||
})
|
||||
});
|
||||
assert.equal(submit.status, 202);
|
||||
const payload = await pollAgentResult(port, traceId);
|
||||
assert.equal(payload.status, "completed");
|
||||
|
||||
const inspect = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/inspect?conversationId=cnv_server-test-inspect&sessionId=ses_server_stdio_pwd&threadId=019e6c72-2373-75e3-9856-eff286db7163`);
|
||||
assert.equal(inspect.status, 200);
|
||||
const body = await inspect.json();
|
||||
assert.equal(body.ok, true);
|
||||
assert.equal(body.status, "found");
|
||||
assert.equal(body.latestTraceId, traceId);
|
||||
assert.equal(body.traceUrl, `/v1/agent/chat/trace/${traceId}`);
|
||||
assert.equal(body.resultUrl, `/v1/agent/chat/result/${traceId}`);
|
||||
assert.equal(body.query.threadId, "019e6c72-2373-75e3-9856-eff286db7163");
|
||||
assert.equal(body.session.sessionId, "ses_server_stdio_pwd");
|
||||
assert.equal(body.conversationFacts.conversationId, "cnv_server-test-inspect");
|
||||
assert.equal(body.runnerTrace.traceId, traceId);
|
||||
assert.equal(body.valuesRedacted, true);
|
||||
assert.equal(body.secretMaterialStored, false);
|
||||
assert.equal(JSON.stringify(body).includes("test-openai-key-material"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat/inspect returns not_found for empty conversation facts", async () => {
|
||||
const server = createCloudApiServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/inspect?conversationId=cnv_server-test-inspect-missing&sessionId=ses_server_test_missing&threadId=019e6c72-2373-75e3-9856-eff286db7163&traceId=trc_server_test_missing`);
|
||||
assert.equal(response.status, 404);
|
||||
const body = await response.json();
|
||||
assert.equal(body.ok, false);
|
||||
assert.equal(body.status, "not_found");
|
||||
assert.equal(body.latestTraceId, null);
|
||||
assert.deepEqual(body.traceIds, []);
|
||||
assert.equal(body.traceUrl, null);
|
||||
assert.equal(body.resultUrl, null);
|
||||
assert.equal(body.conversationFacts.turnCount, 0);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api result polling compacts large runnerTrace while preserving providerTrace", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-"));
|
||||
const codexHome = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-result-compact-codex-home-"));
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT: "32",
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||||
},
|
||||
codexStdioManager: {
|
||||
describe() {
|
||||
return {
|
||||
...codexStdioReadyFixture({ workspace, codexHome }),
|
||||
sandbox: "danger-full-access"
|
||||
};
|
||||
},
|
||||
async probe() {
|
||||
return {
|
||||
...codexStdioReadyFixture({ workspace, codexHome }),
|
||||
sandbox: "danger-full-access"
|
||||
};
|
||||
},
|
||||
async chat(params = {}) {
|
||||
const base = codexStdioChatFixture({ workspace, codexHome, params });
|
||||
const events = Array.from({ length: 160 }, (_, index) => ({
|
||||
seq: index + 1,
|
||||
traceId: params.traceId,
|
||||
type: "assistant_message",
|
||||
status: "chunk",
|
||||
label: index === 159 ? "assistant:completed" : "assistant:chunk",
|
||||
createdAt: "2026-05-23T00:00:00.000Z",
|
||||
waitingFor: index === 159 ? null : "turn/completed",
|
||||
valuesPrinted: false
|
||||
}));
|
||||
return {
|
||||
...base,
|
||||
sandbox: "danger-full-access",
|
||||
session: {
|
||||
...base.session,
|
||||
sandbox: "danger-full-access"
|
||||
},
|
||||
runnerTrace: {
|
||||
...base.runnerTrace,
|
||||
events,
|
||||
eventLabels: events.map((event) => event.label),
|
||||
eventCount: events.length,
|
||||
lastEvent: events.at(-1)
|
||||
}
|
||||
};
|
||||
},
|
||||
cancel() {},
|
||||
reapIdle() {}
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const traceId = "trc_server-test-result-compact";
|
||||
const submit = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": traceId,
|
||||
"prefer": "respond-async",
|
||||
"x-hwlab-short-connection": "1"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-result-compact",
|
||||
message: "生成大量 trace 后返回"
|
||||
})
|
||||
});
|
||||
assert.equal(submit.status, 202);
|
||||
|
||||
const payload = await pollAgentResult(port, traceId);
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.traceId, traceId);
|
||||
assert.equal(payload.providerTrace.protocol, "codex-app-server-jsonrpc-stdio");
|
||||
assert.equal(payload.providerTrace.command, "codex app-server --listen stdio://");
|
||||
assert.equal(payload.runnerTrace.eventCount, 160);
|
||||
assert.equal(payload.runnerTrace.eventsCompacted, true);
|
||||
assert.equal(payload.runnerTrace.events.length, 32);
|
||||
assert.equal(payload.runnerTrace.events.some((event) => event.label === "trace:compacted"), true);
|
||||
assert.equal(payload.runnerTrace.lastEvent.label, "assistant:completed");
|
||||
assert.equal(payload.runnerTrace.eventLabels.includes("trace:compacted"), true);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(codexHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat answers skills prompt through real Codex stdio and retains trace", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const skillsDir = path.join(workspace, "skills");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
await mkdir(path.join(skillsDir, "hwlab-test-skill"), { recursive: true });
|
||||
await prepareFakeCodexHome(codexHome);
|
||||
await writeFile(path.join(skillsDir, "hwlab-test-skill", "SKILL.md"), [
|
||||
"---",
|
||||
"name: hwlab-test-skill",
|
||||
"description: Test skill mounted for Codex stdio discovery.",
|
||||
"version: v-test",
|
||||
"---",
|
||||
"",
|
||||
"# HWLAB Test Skill"
|
||||
].join("\n"));
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_SKILLS_DIRS: skillsDir,
|
||||
HWLAB_CODE_AGENT_SKILLS_STRICT: "1",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||||
},
|
||||
codexStdioManager: createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_server_stdio_skills",
|
||||
createRpcClient: async () => createFakeAppServerClient({
|
||||
text: "模型泛化回答不应成为 skills 最终回复。"
|
||||
})
|
||||
})
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const payload = await postAgent(port, {
|
||||
conversationId: "cnv_server_stdio_skills",
|
||||
traceId: "trc_server_stdio_skills",
|
||||
message: "列出你可用的skills"
|
||||
});
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "completed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
||||
assert.equal(payload.capabilityLevel, "long-lived-codex-stdio-session");
|
||||
assert.equal(payload.providerTrace.sidecarOnly, undefined);
|
||||
assert.equal(payload.providerTrace.toolName, "codex-app-server.thread/start+turn/start");
|
||||
assert.equal(payload.skills.status, "ready");
|
||||
const discovered = payload.skills.items.find((skill) => skill.name === "hwlab-test-skill");
|
||||
assert.ok(discovered);
|
||||
assert.equal(discovered.source, path.join(skillsDir, "hwlab-test-skill", "SKILL.md"));
|
||||
assert.equal(discovered.manifest.path, path.join(skillsDir, "hwlab-test-skill", "SKILL.md"));
|
||||
assert.equal(discovered.version, "v-test");
|
||||
assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "completed"));
|
||||
assert.match(payload.reply.content, /hwlab-test-skill/u);
|
||||
assert.match(payload.reply.content, /真实 skill manifest 清单/u);
|
||||
assert.doesNotMatch(payload.reply.content, /模型泛化回答/u);
|
||||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "prompt:sent"));
|
||||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "tool:codex-app-server.thread/start+turn/start:started"));
|
||||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat returns structured skills blocker without local skills manifest", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-stdio-skills-missing-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
await prepareFakeCodexHome(codexHome);
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_SKILLS_DIRS: path.join(workspace, "missing-skills"),
|
||||
HWLAB_CODE_AGENT_SKILLS_STRICT: "1",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:65535/v1/responses"
|
||||
},
|
||||
codexStdioManager: createCodexStdioSessionManager({
|
||||
idFactory: () => "ses_server_stdio_skills_missing",
|
||||
createRpcClient: async () => createFakeAppServerClient({
|
||||
text: "模型泛化回答:alpha-skill"
|
||||
})
|
||||
})
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const payload = await postAgent(port, {
|
||||
conversationId: "cnv_server_stdio_skills_missing",
|
||||
traceId: "trc_server_stdio_skills_missing",
|
||||
message: "列出你可用的skills"
|
||||
});
|
||||
|
||||
validateCodeAgentChatSchema(payload);
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.backend, "hwlab-cloud-api/codex-app-server-stdio");
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.equal(payload.error.code, "skills_unavailable");
|
||||
assert.equal(payload.skills.status, "blocked");
|
||||
assert.equal(payload.skills.blockers[0].code, "skills_unavailable");
|
||||
assert.deepEqual(payload.skills.sourceIssues, ["pikasTech/HWLAB#136", "pikasTech/HWLAB#237"]);
|
||||
assert.ok(payload.toolCalls.some((toolCall) => toolCall.name === "skills.discover" && toolCall.status === "blocked"));
|
||||
assert.equal(payload.toolCalls.some((toolCall) => toolCall.name === "codex-app-server.thread/start+turn/start"), false);
|
||||
assert.equal(payload.session.status, "idle");
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("alpha-skill"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat blocks forbidden file paths without leaking values", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-file-block-"));
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const payload = await postAgent(port, {
|
||||
conversationId: "cnv_server-test-security-block",
|
||||
traceId: "trc_server-test-security-block",
|
||||
message: "cat ../outside.txt"
|
||||
});
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
|
||||
assert.deepEqual(payload.toolCalls, []);
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.ok(payload.runnerTrace.events.some((event) => event.label === "codex-stdio:blocked"));
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat/cancel reports unsupported lifecycle degradation", async () => {
|
||||
const traceStore = createCodeAgentTraceStore();
|
||||
traceStore.append("trc_cancel_unsupported", {
|
||||
type: "request",
|
||||
status: "running",
|
||||
label: "request:running",
|
||||
sessionId: "ses_cancel_unsupported",
|
||||
sessionStatus: "busy",
|
||||
sessionLifecycleStatus: "busy"
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
traceStore,
|
||||
codexStdioManager: {
|
||||
get() {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/cancel`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_cancel_unsupported"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_cancel_unsupported",
|
||||
sessionId: "ses_cancel_unsupported",
|
||||
traceId: "trc_cancel_unsupported"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 501);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "degraded");
|
||||
assert.equal(payload.unsupported, true);
|
||||
assert.equal(payload.degraded, true);
|
||||
assert.equal(payload.error.code, "cancel_unsupported");
|
||||
assert.equal(payload.sessionLifecycleStatus, "failed");
|
||||
assert.equal(payload.sessionSummary.unsupported, true);
|
||||
assert.match(payload.sessionSummary.userMessage, /unsupported\/degraded/u);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat reports Codex stdio blocker instead of structured skills fallback", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-no-skills-"));
|
||||
const skillsDir = path.join(root, "missing-skills");
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: root
|
||||
},
|
||||
skillsDirs: [skillsDir],
|
||||
skillsDirsExact: true
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-skills-missing",
|
||||
message: "列出你可用的skills"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.notEqual(payload.error.code, "skills_unavailable");
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.equal(payload.skills.status, "not_requested");
|
||||
assert.deepEqual(payload.toolCalls, []);
|
||||
assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback"));
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat does not run unsupported local grep fallback", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-tool-blocked-"));
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-tool-unavailable",
|
||||
message: "请用grep搜索 package.json"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.notEqual(payload.error.code, "tool_unavailable");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.deepEqual(payload.toolCalls, []);
|
||||
assert.ok(payload.runnerLimitations.includes("no-controlled-readonly-fallback"));
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat does not call delayed provider fallback beyond legacy 4500ms UI timeout", async () => {
|
||||
let providerCalled = false;
|
||||
const server = createCloudApiServer({
|
||||
callCodeAgentProvider: async ({ providerPlan, message, traceId }) => {
|
||||
providerCalled = true;
|
||||
await delay(4700);
|
||||
return {
|
||||
provider: providerPlan.provider,
|
||||
model: providerPlan.model,
|
||||
backend: providerPlan.backend,
|
||||
content: `延迟真实 provider stub: ${message} / ${traceId}`,
|
||||
usage: null,
|
||||
providerTrace: {
|
||||
source: "delayed-test-provider"
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const startedAt = Date.now();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_server-test-agent-chat-delayed"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_server-test-agent-chat-delayed",
|
||||
message: "请用一句话说明当前 HWLAB 工作台可以做什么,稍后回答"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(Date.now() - startedAt < 4500, true);
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.equal(payload.conversationId, "cnv_server-test-agent-chat-delayed");
|
||||
assert.equal(payload.traceId, "trc_server-test-agent-chat-delayed");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(payload.capabilityLevel, "blocked");
|
||||
assert.equal(providerCalled, false);
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback", async () => {
|
||||
for (const status of [502, 503]) {
|
||||
const providerServer = createHttpServer((request, response) => {
|
||||
request.resume();
|
||||
response.writeHead(status, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify({
|
||||
error: {
|
||||
message: `upstream ${status}`
|
||||
}
|
||||
}));
|
||||
});
|
||||
await new Promise((resolve) => providerServer.listen(0, "127.0.0.1", resolve));
|
||||
const providerPort = providerServer.address().port;
|
||||
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "openai",
|
||||
HWLAB_CODE_AGENT_ALLOW_TEXT_FALLBACK: "true",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: `http://127.0.0.1:${providerPort}/v1/responses`
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": `trc_server-test-agent-chat-provider-${status}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: `cnv_server-test-agent-chat-provider-${status}`,
|
||||
message: `provider ${status}`
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.equal(payload.traceId, `trc_server-test-agent-chat-provider-${status}`);
|
||||
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
|
||||
assert.match(payload.error.layer, /^(runner|api)$/u);
|
||||
assert.equal(typeof payload.error.retryable, "boolean");
|
||||
assert.equal(payload.error.blocker.traceId, `trc_server-test-agent-chat-provider-${status}`);
|
||||
assert.equal(typeof payload.error.blocker.retryable, "boolean");
|
||||
assert.equal(payload.error.blocker.capabilityLevel, "blocked");
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
providerServer.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/agent/chat does not call empty provider text fallback", async () => {
|
||||
let providerCalled = false;
|
||||
const server = createCloudApiServer({
|
||||
callCodeAgentProvider: async () => {
|
||||
providerCalled = true;
|
||||
throw new Error("empty provider fallback must not be used");
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
conversationId: "cnv_empty-provider-text",
|
||||
message: "你好"
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.conversationId, "cnv_empty-provider-text");
|
||||
assert.equal(payload.status, "failed");
|
||||
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
|
||||
assert.equal(payload.provider, "codex-stdio");
|
||||
assert.equal(Object.hasOwn(payload, "reply"), false);
|
||||
assert.equal(providerCalled, false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,801 @@
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { M3_IO_CONTROL_ROUTE, M3_STATUS_ROUTE, describeM3StatusLive, handleM3IoControl } from "./m3-io-control.ts";
|
||||
import { createCodeAgentErrorPayload, handleCodeAgentChat } from "./code-agent-chat.ts";
|
||||
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
||||
import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts";
|
||||
import {
|
||||
firstHeaderValue,
|
||||
getHeader,
|
||||
parsePositiveInteger,
|
||||
positiveInteger,
|
||||
readBody,
|
||||
safeConversationId,
|
||||
safeOpaqueId,
|
||||
safeSessionId,
|
||||
safeTraceId,
|
||||
sendJson,
|
||||
truthyFlag
|
||||
} from "./server-http-utils.ts";
|
||||
|
||||
const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120;
|
||||
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
|
||||
const CODE_AGENT_PROVIDER_PROFILE_IDS = Object.freeze(["deepseek", "codex-api", "runtime-default"]);
|
||||
const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({
|
||||
"deepseek": "DeepSeek",
|
||||
"codex-api": "Codex API",
|
||||
"runtime-default": "运行默认"
|
||||
});
|
||||
const DEFAULT_CODE_AGENT_CODEX_API_MODEL = "gpt-5.5";
|
||||
const DEFAULT_CODE_AGENT_CODEX_API_BASE_URL = "http://127.0.0.1:49280/responses";
|
||||
const DEFAULT_CODE_AGENT_DEEPSEEK_MODEL = "deepseek-chat";
|
||||
|
||||
export async function handleCodeAgentChatHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(response, 400, {
|
||||
...createCodeAgentErrorPayload({
|
||||
code: "parse_error",
|
||||
message: "Invalid JSON body",
|
||||
reason: error.message,
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
model: options.env?.HWLAB_CODE_AGENT_MODEL || options.env?.OPENAI_MODEL || process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown",
|
||||
layer: "api",
|
||||
retryable: true
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
sendJson(response, 400, {
|
||||
...createCodeAgentErrorPayload({
|
||||
code: "invalid_params",
|
||||
message: "Code Agent chat body must be a JSON object",
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
model: options.env?.HWLAB_CODE_AGENT_MODEL || options.env?.OPENAI_MODEL || process.env.HWLAB_CODE_AGENT_MODEL || process.env.OPENAI_MODEL || "unknown",
|
||||
layer: "api",
|
||||
retryable: true
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId) || `trc_${randomUUID()}`;
|
||||
const chatParams = {
|
||||
...params,
|
||||
traceId
|
||||
};
|
||||
|
||||
if (codeAgentChatShortConnectionRequested(request, params, options)) {
|
||||
submitCodeAgentChatTurn({
|
||||
params: chatParams,
|
||||
options,
|
||||
traceId
|
||||
});
|
||||
const traceUrl = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`;
|
||||
sendJson(response, 202, {
|
||||
accepted: true,
|
||||
status: "running",
|
||||
shortConnection: true,
|
||||
controlSemantics: "submit-and-poll",
|
||||
traceId,
|
||||
conversationId: safeConversationId(params.conversationId) || null,
|
||||
sessionId: safeSessionId(params.sessionId) || null,
|
||||
traceUrl,
|
||||
resultUrl: `/v1/agent/chat/result/${encodeURIComponent(traceId)}`,
|
||||
streamUrl: `${traceUrl}/stream`,
|
||||
cancelUrl: "/v1/agent/chat/cancel",
|
||||
polling: {
|
||||
resultIntervalMs: parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_RESULT_POLL_INTERVAL_MS, 1000),
|
||||
traceIntervalMs: parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_TRACE_POLL_INTERVAL_MS, 1000)
|
||||
},
|
||||
runnerTrace: (options.traceStore ?? defaultCodeAgentTraceStore).snapshot(traceId)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = await runCodeAgentChat(chatParams, options);
|
||||
|
||||
sendJson(response, payload.status === "failed" && payload.error?.code === "invalid_params" ? 400 : 200, payload);
|
||||
}
|
||||
|
||||
function runCodeAgentChat(params, options) {
|
||||
return handleCodeAgentChat(params, codeAgentChatExecutionOptions(options, params));
|
||||
}
|
||||
|
||||
function codeAgentChatExecutionOptions(options = {}, params = {}) {
|
||||
return {
|
||||
callProvider: options.callCodeAgentProvider,
|
||||
timeoutMs: options.codeAgentTimeoutMs,
|
||||
hardTimeoutMs: options.codeAgentHardTimeoutMs,
|
||||
env: codeAgentProviderProfileEnv(options.env ?? process.env, params),
|
||||
workspace: options.workspace,
|
||||
skillsDirs: options.skillsDirs,
|
||||
skillsDirsExact: options.skillsDirsExact,
|
||||
skillsDiscoveryDelayMs: options.skillsDiscoveryDelayMs,
|
||||
sessionRegistry: options.sessionRegistry,
|
||||
m3IoSkillRequestJson: options.m3IoSkillRequestJson ?? createCodeAgentM3HwlabApiRequestJson(options),
|
||||
codexStdioManager: options.codexStdioManager,
|
||||
traceStore: options.traceStore,
|
||||
gatewayRegistry: options.gatewayRegistry
|
||||
};
|
||||
}
|
||||
|
||||
function codeAgentProviderProfileEnv(env = process.env, params = {}) {
|
||||
const profile = normalizeCodeAgentProviderProfile(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE);
|
||||
if (profile === "runtime-default") return env;
|
||||
const overlay = { ...env };
|
||||
overlay.HWLAB_CODE_AGENT_SELECTED_PROVIDER_PROFILE = profile;
|
||||
overlay.HWLAB_CODE_AGENT_PROVIDER_PROFILE_LABEL = CODE_AGENT_PROVIDER_PROFILE_LABELS[profile] ?? profile;
|
||||
overlay.HWLAB_CODE_AGENT_PROVIDER = "codex-stdio";
|
||||
if (profile === "codex-api") {
|
||||
overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_CODEX_API_MODEL, env.HWLAB_CODE_AGENT_LEGACY_CODEX_MODEL, DEFAULT_CODE_AGENT_CODEX_API_MODEL);
|
||||
overlay.HWLAB_CODE_AGENT_OPENAI_BASE_URL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_CODEX_API_BASE_URL, env.HWLAB_CODE_AGENT_LEGACY_OPENAI_BASE_URL, DEFAULT_CODE_AGENT_CODEX_API_BASE_URL);
|
||||
} else {
|
||||
overlay.HWLAB_CODE_AGENT_MODEL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_MODEL, DEFAULT_CODE_AGENT_DEEPSEEK_MODEL);
|
||||
overlay.HWLAB_CODE_AGENT_OPENAI_BASE_URL = firstNonEmptyValue(env.HWLAB_CODE_AGENT_DEEPSEEK_BASE_URL, defaultDeepSeekBaseUrlForEnv(env));
|
||||
}
|
||||
overlay.NO_PROXY = mergeNoProxyEntries(env.NO_PROXY, codeAgentClusterNoProxyEntries(env));
|
||||
overlay.no_proxy = mergeNoProxyEntries(env.no_proxy ?? env.NO_PROXY, codeAgentClusterNoProxyEntries(env));
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function normalizeCodeAgentProviderProfile(value) {
|
||||
const text = String(value ?? DEFAULT_CODE_AGENT_PROVIDER_PROFILE).trim().toLowerCase();
|
||||
return CODE_AGENT_PROVIDER_PROFILE_IDS.includes(text) ? text : DEFAULT_CODE_AGENT_PROVIDER_PROFILE;
|
||||
}
|
||||
|
||||
function defaultDeepSeekBaseUrlForEnv(env = process.env) {
|
||||
const namespace = env.HWLAB_GITOPS_PROFILE === "prod" || env.HWLAB_ENVIRONMENT === "prod" ? "hwlab-prod" : "hwlab-dev";
|
||||
return `http://hwlab-deepseek-proxy.${namespace}.svc.cluster.local:4000/v1/responses`;
|
||||
}
|
||||
|
||||
function codeAgentClusterNoProxyEntries(env = process.env) {
|
||||
const namespace = env.HWLAB_GITOPS_PROFILE === "prod" || env.HWLAB_ENVIRONMENT === "prod" ? "hwlab-prod" : "hwlab-dev";
|
||||
return [
|
||||
`${namespace}.svc.cluster.local`,
|
||||
".svc",
|
||||
".cluster.local",
|
||||
"127.0.0.1",
|
||||
"localhost",
|
||||
"::1",
|
||||
"10.0.0.0/8",
|
||||
"10.42.0.0/16",
|
||||
"10.43.0.0/16",
|
||||
"hyueapi.com",
|
||||
".hyueapi.com",
|
||||
"hyui.com",
|
||||
".hyui.com",
|
||||
"api.minimaxi.com",
|
||||
".minimaxi.com"
|
||||
];
|
||||
}
|
||||
|
||||
function mergeNoProxyEntries(current, additions = []) {
|
||||
return uniqueStrings([...String(current ?? "").split(","), ...additions]).join(",");
|
||||
}
|
||||
|
||||
function firstNonEmptyValue(...values) {
|
||||
for (const value of values) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function submitCodeAgentChatTurn({ params, options, traceId }) {
|
||||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||||
const results = options.codeAgentChatResults ?? createCodeAgentChatResultStore();
|
||||
results.set(traceId, {
|
||||
accepted: true,
|
||||
status: "running",
|
||||
traceId,
|
||||
conversationId: safeConversationId(params.conversationId) || null,
|
||||
sessionId: safeSessionId(params.sessionId) || null,
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
traceStore.ensure(traceId, {
|
||||
runnerKind: "codex-app-server-stdio-runner",
|
||||
workspace: options.workspace ?? options.env?.HWLAB_CODE_AGENT_CODEX_WORKSPACE ?? options.env?.HWLAB_CODE_AGENT_WORKSPACE ?? null,
|
||||
sandbox: options.env?.HWLAB_CODE_AGENT_CODEX_SANDBOX ?? null,
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session"
|
||||
});
|
||||
traceStore.append(traceId, {
|
||||
type: "request",
|
||||
status: "accepted",
|
||||
label: "request:accepted-short-connection",
|
||||
message: "Code Agent request accepted; client must poll trace/result with short HTTP requests.",
|
||||
waitingFor: "codex-stdio"
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const payload = await runCodeAgentChat(params, options);
|
||||
if (isCodeAgentResultCanceled(results.get(traceId))) return;
|
||||
results.set(traceId, payload);
|
||||
traceStore.append(traceId, {
|
||||
type: "result",
|
||||
status: payload.status === "completed" ? "completed" : "failed",
|
||||
label: `result:${payload.status}`,
|
||||
message: payload.status === "completed"
|
||||
? "Code Agent result is ready for short-connection polling."
|
||||
: payload.error?.message ?? "Code Agent completed without a successful reply.",
|
||||
sessionId: payload.sessionId ?? payload.session?.sessionId,
|
||||
sessionStatus: payload.session?.status,
|
||||
terminal: true
|
||||
});
|
||||
} catch (error) {
|
||||
if (isCodeAgentResultCanceled(results.get(traceId))) return;
|
||||
const payload = {
|
||||
status: "failed",
|
||||
traceId,
|
||||
conversationId: safeConversationId(params.conversationId) || null,
|
||||
sessionId: safeSessionId(params.sessionId) || null,
|
||||
error: {
|
||||
code: "code_agent_background_failed",
|
||||
layer: "api",
|
||||
retryable: true,
|
||||
message: error?.message ?? "Code Agent background turn failed",
|
||||
userMessage: "Code Agent 后台任务失败;trace/result 轮询已保留错误。"
|
||||
},
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
results.set(traceId, payload);
|
||||
traceStore.append(traceId, {
|
||||
type: "result",
|
||||
status: "failed",
|
||||
label: "result:failed",
|
||||
errorCode: payload.error.code,
|
||||
message: payload.error.message,
|
||||
terminal: true
|
||||
});
|
||||
}
|
||||
};
|
||||
setImmediate(() => {
|
||||
run();
|
||||
});
|
||||
}
|
||||
|
||||
function codeAgentChatShortConnectionRequested(request, params, options = {}) {
|
||||
const header = firstHeaderValue(request, "prefer");
|
||||
const explicit = firstHeaderValue(request, "x-hwlab-short-connection");
|
||||
const envValue = options.env?.HWLAB_CODE_AGENT_CHAT_SHORT_CONNECTION;
|
||||
return /(?:^|,)\s*respond-async\s*(?:,|$)/iu.test(String(header ?? "")) ||
|
||||
truthyFlag(explicit) ||
|
||||
params?.shortConnection === true ||
|
||||
truthyFlag(envValue);
|
||||
}
|
||||
|
||||
export async function handleCodeAgentChatResultHttp(request, response, url, options) {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
const traceId = decodeURIComponent(parts[4] ?? "");
|
||||
if (!safeTraceId(traceId)) {
|
||||
sendJson(response, 400, {
|
||||
error: {
|
||||
code: "invalid_trace_id",
|
||||
message: "traceId must start with trc_ and contain only safe identifier characters"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
const results = options.codeAgentChatResults;
|
||||
const result = results?.get(traceId) ?? null;
|
||||
if (result && result.status !== "running") {
|
||||
sendJson(response, 200, compactCodeAgentChatResultPayload(result, options));
|
||||
return;
|
||||
}
|
||||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||||
const runnerTrace = traceStore.snapshot(traceId);
|
||||
if (result || runnerTrace.status !== "missing") {
|
||||
sendJson(response, 202, {
|
||||
accepted: true,
|
||||
status: "running",
|
||||
shortConnection: true,
|
||||
traceId,
|
||||
runnerTrace: compactRunnerTraceForResult(runnerTrace, resultTraceEventLimit(options)),
|
||||
waitingFor: runnerTrace.waitingFor ?? "codex-stdio"
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, {
|
||||
error: {
|
||||
code: "code_agent_result_not_found",
|
||||
message: `No Code Agent result is registered for ${traceId}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleCodeAgentInspectHttp(request, response, url, options) {
|
||||
const query = {
|
||||
conversationId: safeConversationId(url.searchParams.get("conversationId")),
|
||||
sessionId: safeSessionId(url.searchParams.get("sessionId")),
|
||||
threadId: safeOpaqueId(url.searchParams.get("threadId")),
|
||||
traceId: safeTraceId(url.searchParams.get("traceId"))
|
||||
};
|
||||
const sessionRegistry = options.sessionRegistry;
|
||||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||||
const manager = options.codexStdioManager;
|
||||
const managerDescription = manager && typeof manager.describe === "function" ? manager.describe() : null;
|
||||
const managerRecentSessions = Array.isArray(managerDescription?.recentSessions) ? managerDescription.recentSessions : [];
|
||||
const directManagerSession = query.sessionId && manager && typeof manager.get === "function"
|
||||
? manager.get(query.sessionId, { conversationId: query.conversationId })
|
||||
: null;
|
||||
const matchedManagerSession = directManagerSession ?? managerRecentSessions.find((session) => sessionMatchesInspectQuery(session, query)) ?? null;
|
||||
const registryInspect = sessionRegistry && typeof sessionRegistry.inspect === "function"
|
||||
? sessionRegistry.inspect(query)
|
||||
: { ok: false, status: "unavailable", traceIds: [], session: null, conversationFacts: null };
|
||||
const session = matchedManagerSession ?? registryInspect.session ?? null;
|
||||
const conversationFacts = registryInspect.conversationFacts ?? null;
|
||||
const requestedRunnerTrace = query.traceId ? traceStore.snapshot(query.traceId) : null;
|
||||
const requestedTraceFound = Boolean(requestedRunnerTrace && requestedRunnerTrace.status !== "missing");
|
||||
const traceIds = uniqueStrings([
|
||||
requestedTraceFound ? query.traceId : null,
|
||||
session?.currentTraceId,
|
||||
session?.lastTraceId,
|
||||
conversationFacts?.latestTraceId,
|
||||
...(Array.isArray(conversationFacts?.traceIds) ? conversationFacts.traceIds : []),
|
||||
...(Array.isArray(registryInspect.traceIds) ? registryInspect.traceIds : [])
|
||||
]);
|
||||
const latestTraceId = traceIds[0] ?? null;
|
||||
const runnerTrace = latestTraceId
|
||||
? latestTraceId === query.traceId && requestedRunnerTrace
|
||||
? requestedRunnerTrace
|
||||
: traceStore.snapshot(latestTraceId)
|
||||
: null;
|
||||
const found = Boolean(registryInspect.ok || session || latestTraceId);
|
||||
sendJson(response, found ? 200 : 404, {
|
||||
ok: found,
|
||||
action: "code-agent.chat.inspect",
|
||||
status: found ? "found" : "not_found",
|
||||
query,
|
||||
session,
|
||||
conversationFacts,
|
||||
traceIds,
|
||||
latestTraceId,
|
||||
traceUrl: latestTraceId ? `/v1/agent/chat/trace/${encodeURIComponent(latestTraceId)}` : null,
|
||||
resultUrl: latestTraceId ? `/v1/agent/chat/result/${encodeURIComponent(latestTraceId)}` : null,
|
||||
runnerTrace,
|
||||
valuesRedacted: true,
|
||||
secretMaterialStored: false
|
||||
});
|
||||
}
|
||||
|
||||
function sessionMatchesInspectQuery(session, query) {
|
||||
if (!session || typeof session !== "object") return false;
|
||||
if (query.sessionId && session.sessionId === query.sessionId) return true;
|
||||
if (query.threadId && session.threadId === query.threadId) return true;
|
||||
if (query.conversationId && session.conversationId === query.conversationId) return true;
|
||||
if (query.traceId && (session.currentTraceId === query.traceId || session.lastTraceId === query.traceId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function handleCodeAgentCancelHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(response, 400, {
|
||||
...createCodeAgentErrorPayload({
|
||||
code: "parse_error",
|
||||
message: "Invalid JSON body",
|
||||
reason: error.message,
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
layer: "api",
|
||||
retryable: true
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
sendJson(response, 400, {
|
||||
...createCodeAgentErrorPayload({
|
||||
code: "invalid_params",
|
||||
message: "Code Agent cancel body must be a JSON object",
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
layer: "api",
|
||||
retryable: true
|
||||
})
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId);
|
||||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||||
const snapshot = traceId ? traceStore.snapshot(traceId) : null;
|
||||
const sessionId = safeSessionId(params.sessionId) || safeSessionId(snapshot?.sessionId);
|
||||
const conversationId = safeConversationId(params.conversationId);
|
||||
const manager = options.codexStdioManager;
|
||||
|
||||
if (!traceId) {
|
||||
sendJson(response, 400, cancelBlockedPayload({
|
||||
code: "cancel_trace_missing",
|
||||
message: "traceId is required to cancel the current Code Agent request.",
|
||||
traceId: "trc_unassigned",
|
||||
conversationId,
|
||||
sessionId
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!manager || typeof manager.get !== "function" || typeof manager.cancel !== "function") {
|
||||
traceStore.append(traceId, {
|
||||
type: "cancel",
|
||||
status: "unsupported",
|
||||
label: "cancel:unsupported",
|
||||
errorCode: "cancel_unsupported",
|
||||
message: "Codex stdio cancel/interrupt backend is not available on this runtime.",
|
||||
waitingFor: "session-control"
|
||||
});
|
||||
const runnerTrace = traceStore.snapshot(traceId);
|
||||
sendJson(response, 501, cancelBlockedPayload({
|
||||
code: "cancel_unsupported",
|
||||
message: "当前后端不支持 Code Agent interrupt/cancel;本次按 unsupported/degraded 返回,不会静默假装已中断。",
|
||||
traceId,
|
||||
conversationId,
|
||||
sessionId,
|
||||
runnerTrace,
|
||||
status: "degraded",
|
||||
unsupported: true,
|
||||
degraded: true
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
traceStore.append(traceId, {
|
||||
type: "cancel",
|
||||
status: "blocked",
|
||||
label: "cancel:not_cancelable",
|
||||
errorCode: "cancel_session_missing",
|
||||
message: "Cancel request did not include a bound Codex stdio sessionId.",
|
||||
waitingFor: "session-binding"
|
||||
});
|
||||
sendJson(response, 409, cancelBlockedPayload({
|
||||
code: "cancel_session_missing",
|
||||
message: "当前请求尚未暴露可取消的 Codex stdio sessionId;页面不能只隐藏 UI,已保留输入和 trace。",
|
||||
traceId,
|
||||
conversationId,
|
||||
sessionId: null,
|
||||
runnerTrace: traceStore.snapshot(traceId)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSession = manager.get(sessionId, { conversationId }) ?? null;
|
||||
if (!currentSession || !["busy", "creating"].includes(currentSession.status)) {
|
||||
traceStore.append(traceId, {
|
||||
type: "cancel",
|
||||
status: "blocked",
|
||||
label: "cancel:not_in_flight",
|
||||
errorCode: "cancel_not_in_flight",
|
||||
message: `Session ${sessionId} is not an in-flight Codex stdio request.`,
|
||||
sessionId,
|
||||
sessionStatus: currentSession?.status ?? "missing"
|
||||
});
|
||||
sendJson(response, 409, cancelBlockedPayload({
|
||||
code: currentSession ? "cancel_not_in_flight" : "cancel_session_not_found",
|
||||
message: currentSession
|
||||
? `当前 session 状态为 ${currentSession.status},没有可取消的 in-flight Codex stdio 请求。`
|
||||
: `没有找到 sessionId=${sessionId} 的 Codex stdio session。`,
|
||||
traceId,
|
||||
conversationId,
|
||||
sessionId,
|
||||
session: currentSession,
|
||||
runnerTrace: traceStore.snapshot(traceId)
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const canceledSession = manager.cancel(sessionId, {
|
||||
traceId,
|
||||
conversationId,
|
||||
reason: "user_cancel"
|
||||
});
|
||||
traceStore.append(traceId, {
|
||||
type: "cancel",
|
||||
status: "canceled",
|
||||
label: "cancel:canceled",
|
||||
message: "User canceled the current Codex stdio request.",
|
||||
sessionId,
|
||||
sessionStatus: canceledSession?.status ?? "canceled",
|
||||
sessionLifecycleStatus: codeAgentSessionLifecycleSummary({ session: canceledSession, status: "canceled" }).status,
|
||||
waitingFor: "user-retry",
|
||||
terminal: true
|
||||
});
|
||||
const runnerTraceSnapshot = traceStore.snapshot(traceId);
|
||||
const sessionSummary = codeAgentSessionLifecycleSummary({
|
||||
session: canceledSession,
|
||||
runnerTrace: runnerTraceSnapshot,
|
||||
status: "canceled"
|
||||
});
|
||||
const runnerTrace = {
|
||||
...runnerTraceSnapshot,
|
||||
sessionLifecycleStatus: sessionSummary.status
|
||||
};
|
||||
const payload = {
|
||||
accepted: true,
|
||||
canceled: true,
|
||||
status: "canceled",
|
||||
conversationId: conversationId ?? canceledSession?.conversationId ?? null,
|
||||
sessionId,
|
||||
traceId,
|
||||
session: canceledSession,
|
||||
sessionLifecycleStatus: sessionSummary.status,
|
||||
sessionLifecycle: sessionSummary,
|
||||
sessionSummary,
|
||||
runnerTrace,
|
||||
lastTraceEvent: runnerTrace.lastEvent,
|
||||
retryable: true,
|
||||
error: {
|
||||
code: "codex_stdio_canceled",
|
||||
layer: "session",
|
||||
category: "canceled",
|
||||
retryable: true,
|
||||
message: "user canceled current Code Agent request",
|
||||
userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
|
||||
traceId,
|
||||
route: "/v1/agent/chat/cancel",
|
||||
toolName: "codex-stdio.cancel"
|
||||
},
|
||||
userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。",
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
options.codeAgentChatResults?.set(traceId, payload);
|
||||
sendJson(response, 200, payload);
|
||||
}
|
||||
|
||||
function isCodeAgentResultCanceled(result) {
|
||||
return result?.status === "canceled" || result?.canceled === true || result?.error?.code === "codex_stdio_canceled";
|
||||
}
|
||||
|
||||
function cancelBlockedPayload({
|
||||
code,
|
||||
message,
|
||||
traceId,
|
||||
conversationId = null,
|
||||
sessionId = null,
|
||||
session = null,
|
||||
runnerTrace = null,
|
||||
status = "failed",
|
||||
unsupported = false,
|
||||
degraded = false
|
||||
}) {
|
||||
const sessionSummary = codeAgentSessionLifecycleSummary({
|
||||
session,
|
||||
runnerTrace,
|
||||
status,
|
||||
unsupported,
|
||||
degraded,
|
||||
error: { code, userMessage: message, message }
|
||||
});
|
||||
return {
|
||||
accepted: false,
|
||||
canceled: false,
|
||||
status,
|
||||
conversationId,
|
||||
sessionId,
|
||||
traceId,
|
||||
session,
|
||||
sessionLifecycleStatus: sessionSummary.status,
|
||||
sessionLifecycle: sessionSummary,
|
||||
sessionSummary,
|
||||
unsupported,
|
||||
degraded,
|
||||
runnerTrace,
|
||||
lastTraceEvent: runnerTrace?.lastEvent ?? null,
|
||||
error: {
|
||||
code,
|
||||
layer: "session",
|
||||
category: "cancel_blocked",
|
||||
retryable: true,
|
||||
userMessage: message,
|
||||
message,
|
||||
traceId,
|
||||
route: "/v1/agent/chat/cancel",
|
||||
toolName: "codex-stdio.cancel"
|
||||
},
|
||||
blocker: {
|
||||
code,
|
||||
layer: "session",
|
||||
category: "cancel_blocked",
|
||||
retryable: true,
|
||||
summary: message,
|
||||
userMessage: message,
|
||||
traceId,
|
||||
route: "/v1/agent/chat/cancel",
|
||||
toolName: "codex-stdio.cancel"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function handleCodeAgentTraceHttp(request, response, url, options) {
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
const traceId = decodeURIComponent(parts[4] ?? "");
|
||||
const traceStore = options.traceStore ?? defaultCodeAgentTraceStore;
|
||||
if (!/^trc_[A-Za-z0-9_.:-]+$/u.test(traceId)) {
|
||||
sendJson(response, 400, {
|
||||
error: {
|
||||
code: "invalid_trace_id",
|
||||
message: "traceId must start with trc_ and contain only safe identifier characters"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (parts[5] === "stream") {
|
||||
sendTraceSse(response, traceStore, traceId, options);
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(response, 200, traceStore.snapshot(traceId));
|
||||
}
|
||||
|
||||
function sendTraceSse(response, traceStore, traceId, options = {}) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
connection: "keep-alive",
|
||||
"x-accel-buffering": "no"
|
||||
});
|
||||
const writeEvent = (eventName, payload) => {
|
||||
response.write(`event: ${eventName}\n`);
|
||||
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
||||
};
|
||||
writeEvent("snapshot", traceStore.snapshot(traceId));
|
||||
const unsubscribe = traceStore.subscribe(traceId, (event, snapshot) => {
|
||||
writeEvent("runnerTrace", { event, snapshot });
|
||||
});
|
||||
const heartbeatMs = parsePositiveInteger(options.env?.HWLAB_CODE_AGENT_TRACE_HEARTBEAT_MS, 15000);
|
||||
const heartbeat = setInterval(() => {
|
||||
writeEvent("heartbeat", traceStore.snapshot(traceId));
|
||||
}, heartbeatMs);
|
||||
response.on("close", () => {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
export function createCodeAgentChatResultStore({ maxResults = 256 } = {}) {
|
||||
const limit = positiveInteger(maxResults, 256);
|
||||
const results = new Map();
|
||||
function set(traceId, payload) {
|
||||
const id = safeTraceId(traceId);
|
||||
if (!id) return;
|
||||
results.set(id, {
|
||||
...payload,
|
||||
traceId: id,
|
||||
cachedAt: new Date().toISOString()
|
||||
});
|
||||
while (results.size > limit) {
|
||||
const oldest = results.keys().next().value;
|
||||
if (!oldest) break;
|
||||
results.delete(oldest);
|
||||
}
|
||||
}
|
||||
return {
|
||||
set,
|
||||
get: (traceId) => results.get(safeTraceId(traceId)) ?? null,
|
||||
clear: () => results.clear()
|
||||
};
|
||||
}
|
||||
|
||||
function compactCodeAgentChatResultPayload(payload, options = {}) {
|
||||
if (!payload || typeof payload !== "object") return payload;
|
||||
const limit = resultTraceEventLimit(options);
|
||||
return {
|
||||
...payload,
|
||||
...(payload.runnerTrace && typeof payload.runnerTrace === "object"
|
||||
? { runnerTrace: compactRunnerTraceForResult(payload.runnerTrace, limit) }
|
||||
: {})
|
||||
};
|
||||
}
|
||||
|
||||
function compactRunnerTraceForResult(runnerTrace, limit = DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT) {
|
||||
if (!runnerTrace || typeof runnerTrace !== "object") return runnerTrace;
|
||||
const events = Array.isArray(runnerTrace.events) ? runnerTrace.events : [];
|
||||
const total = Number.isInteger(runnerTrace.eventCount) ? runnerTrace.eventCount : events.length;
|
||||
const effectiveLimit = Math.max(8, positiveInteger(limit, DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT));
|
||||
if (events.length <= effectiveLimit) {
|
||||
return {
|
||||
...runnerTrace,
|
||||
eventCount: total,
|
||||
eventsCompacted: false
|
||||
};
|
||||
}
|
||||
const headCount = Math.min(24, Math.max(1, Math.floor(effectiveLimit / 4)));
|
||||
const tailCount = Math.max(1, effectiveLimit - headCount - 1);
|
||||
const omitted = Math.max(0, events.length - headCount - tailCount);
|
||||
const head = events.slice(0, headCount);
|
||||
const tail = events.slice(events.length - tailCount);
|
||||
const marker = {
|
||||
traceId: runnerTrace.traceId ?? head[0]?.traceId ?? tail[0]?.traceId ?? null,
|
||||
type: "trace",
|
||||
stage: "trace",
|
||||
status: "truncated",
|
||||
label: "trace:compacted",
|
||||
createdAt: tail[0]?.createdAt ?? runnerTrace.updatedAt ?? new Date().toISOString(),
|
||||
message: `Result polling response compacted ${omitted} trace events; fetch /v1/agent/chat/trace/${encodeURIComponent(runnerTrace.traceId ?? "")} for the full trace.`,
|
||||
omittedEventCount: omitted,
|
||||
retainedEventCount: head.length + tail.length,
|
||||
totalEventCount: total,
|
||||
valuesPrinted: false
|
||||
};
|
||||
const compactEvents = [...head, marker, ...tail];
|
||||
return {
|
||||
...runnerTrace,
|
||||
eventCount: total,
|
||||
events: compactEvents,
|
||||
eventLabels: compactEvents.map(traceEventLabel).filter(Boolean),
|
||||
eventsCompacted: true,
|
||||
eventWindow: {
|
||||
mode: "head-tail",
|
||||
retained: compactEvents.length,
|
||||
omitted,
|
||||
total
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function traceEventLabel(event) {
|
||||
return typeof event === "string" ? event : event?.label;
|
||||
}
|
||||
|
||||
function resultTraceEventLimit(options = {}) {
|
||||
return positiveInteger(
|
||||
options.env?.HWLAB_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT,
|
||||
DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT
|
||||
);
|
||||
}
|
||||
|
||||
function createCodeAgentM3HwlabApiRequestJson(options = {}) {
|
||||
return async (targetUrl, request = {}) => {
|
||||
const url = new URL(targetUrl);
|
||||
const route = url.pathname;
|
||||
if (route === M3_IO_CONTROL_ROUTE && request.method === "POST") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: await handleM3IoControl(request.body ?? {}, {
|
||||
runtimeStore: options.runtimeStore,
|
||||
now: options.now,
|
||||
env: options.env,
|
||||
requestJson: options.m3IoRequestJson
|
||||
})
|
||||
};
|
||||
}
|
||||
if (route === M3_STATUS_ROUTE && request.method === "GET") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: await describeM3StatusLive(options)
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
body: {
|
||||
error: {
|
||||
code: "not_found",
|
||||
message: `Unsupported Code Agent M3 HWLAB API route ${route}`
|
||||
}
|
||||
},
|
||||
error: `Unsupported Code Agent M3 HWLAB API route ${route}`
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
|
||||
}
|
||||
@@ -0,0 +1,847 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer as createTcpServer } from "node:net";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.ts";
|
||||
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
|
||||
import {
|
||||
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED
|
||||
} from "../db/runtime-store.ts";
|
||||
import {
|
||||
createFakeCodexCommand,
|
||||
durableBlockedRuntime,
|
||||
durableReadyRuntime,
|
||||
layerForBlockedCase,
|
||||
layerStatus,
|
||||
prepareFakeCodexHome
|
||||
} from "./server-test-helpers.ts";
|
||||
|
||||
test("cloud api health reports DB env presence and live connection classification without leaking values", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
const originalAliasEnv = {
|
||||
HWLAB_CLOUD_DB_SERVICE_NAME: process.env.HWLAB_CLOUD_DB_SERVICE_NAME,
|
||||
HWLAB_CLOUD_DB_SERVICE_NAMESPACE: process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE,
|
||||
HWLAB_CLOUD_DB_HOST: process.env.HWLAB_CLOUD_DB_HOST,
|
||||
HWLAB_CLOUD_DB_PORT: process.env.HWLAB_CLOUD_DB_PORT
|
||||
};
|
||||
const fakeDb = createTcpServer((socket) => socket.end());
|
||||
await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve));
|
||||
const dbPort = fakeDb.address().port;
|
||||
process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test@127.0.0.1:${dbPort}/hwlab`;
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable";
|
||||
delete process.env.HWLAB_CLOUD_DB_SERVICE_NAME;
|
||||
delete process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE;
|
||||
delete process.env.HWLAB_CLOUD_DB_HOST;
|
||||
delete process.env.HWLAB_CLOUD_DB_PORT;
|
||||
|
||||
const server = createCloudApiServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "degraded");
|
||||
assert.equal(payload.db.status, "ready");
|
||||
assert.equal(payload.db.connected, true);
|
||||
assert.equal(payload.db.liveConnected, true);
|
||||
assert.equal(payload.db.liveDbEvidence, true);
|
||||
assert.equal(payload.db.connectionChecked, true);
|
||||
assert.equal(payload.db.connectionAttempted, true);
|
||||
assert.equal(payload.db.connectionResult, "connected");
|
||||
assert.equal(payload.db.endpointSource, "secret-url-host");
|
||||
assert.equal(payload.db.connection.endpointSource, "secret-url-host");
|
||||
assert.equal(payload.db.endpoint.authoritative.source, "secret-url-host");
|
||||
assert.equal(payload.db.endpoint.authoritative.env, "HWLAB_CLOUD_DB_URL");
|
||||
assert.equal(payload.db.optionalPublicDnsAlias.source, "optional-public-dns-alias");
|
||||
assert.equal(payload.db.optionalPublicDnsAlias.requiredForReadiness, false);
|
||||
assert.equal(payload.db.optionalPublicDnsAlias.usedForProbe, false);
|
||||
assert.equal(payload.db.optionalPublicDnsAlias.envPresent, false);
|
||||
assert.equal(payload.db.configReady, true);
|
||||
assert.equal(payload.db.ready, true);
|
||||
assert.equal(payload.db.evidence, "live_db_tcp_connection_ready");
|
||||
assert.equal(payload.db.safety.liveDbEvidence, true);
|
||||
assert.equal(payload.db.safety.liveDbConnectedEvidence, true);
|
||||
assert.equal(payload.runtime.adapter, "memory");
|
||||
assert.equal(payload.runtime.durable, false);
|
||||
assert.equal(payload.runtime.status, "degraded");
|
||||
assert.match(payload.runtime.reason, /process-local/u);
|
||||
assert.equal(payload.runtime.durabilityContract.blockedLayer, "adapter");
|
||||
assert.equal(payload.runtime.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
assert.equal(payload.readiness.components.db, "ready");
|
||||
assert.equal(payload.readiness.components.runtime, "blocked");
|
||||
assert.equal(payload.readiness.durability.status, "blocked");
|
||||
assert.equal(payload.readiness.durability.dbLiveEvidenceObserved, true);
|
||||
assert.equal(payload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
assert.equal(payload.readiness.durability.blockedLayer, "adapter");
|
||||
assert.equal(payload.readiness.durability.liveRuntimeEvidence, false);
|
||||
assert.equal(payload.readiness.provider.status, "blocked");
|
||||
assert.equal(payload.readiness.dbDurable.status, "blocked");
|
||||
assert.equal(payload.readiness.dbDurable.dbLiveEvidenceObserved, true);
|
||||
assert.equal(payload.readiness.dbDurable.blocker, "runtime_durable_adapter_missing");
|
||||
assert.equal(payload.readiness.sessionRunner.status, "blocked");
|
||||
assert.equal(payload.readiness.codexStdio.status, "blocked");
|
||||
assert.equal(payload.db.redaction.valuesRedacted, true);
|
||||
assert.equal(payload.db.redaction.endpointRedacted, true);
|
||||
assert.equal(payload.db.redaction.secretMaterialRead, false);
|
||||
assert.deepEqual(payload.db.missingEnv, []);
|
||||
assert.equal(JSON.stringify(payload.db).includes("password"), false);
|
||||
assert.equal(JSON.stringify(payload.db).includes("127.0.0.1"), false);
|
||||
assert.equal(JSON.stringify(payload.db).includes(String(dbPort)), false);
|
||||
assert.equal(JSON.stringify(payload.db).includes("cloud-api-db.hwlab-dev.svc.cluster.local"), false);
|
||||
assert.equal(payload.db.fields.find((field) => field.name === "HWLAB_CLOUD_DB_URL").redacted, true);
|
||||
assert.equal(payload.db.connection.endpointRedacted, true);
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_URL;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_URL = originalUrl;
|
||||
}
|
||||
if (originalSslMode === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode;
|
||||
}
|
||||
for (const [name, value] of Object.entries(originalAliasEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
fakeDb.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health reports the v02 DB contract under the v02 profile", async () => {
|
||||
const originalEnv = {
|
||||
HWLAB_ENVIRONMENT: process.env.HWLAB_ENVIRONMENT,
|
||||
HWLAB_GITOPS_PROFILE: process.env.HWLAB_GITOPS_PROFILE,
|
||||
HWLAB_CLOUD_DB_URL: process.env.HWLAB_CLOUD_DB_URL,
|
||||
HWLAB_CLOUD_DB_SSL_MODE: process.env.HWLAB_CLOUD_DB_SSL_MODE,
|
||||
HWLAB_CLOUD_DB_SERVICE_NAME: process.env.HWLAB_CLOUD_DB_SERVICE_NAME,
|
||||
HWLAB_CLOUD_DB_SERVICE_NAMESPACE: process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE,
|
||||
HWLAB_CLOUD_DB_HOST: process.env.HWLAB_CLOUD_DB_HOST,
|
||||
HWLAB_CLOUD_DB_PORT: process.env.HWLAB_CLOUD_DB_PORT
|
||||
};
|
||||
const fakeDb = createTcpServer((socket) => socket.end());
|
||||
await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve));
|
||||
const dbPort = fakeDb.address().port;
|
||||
process.env.HWLAB_ENVIRONMENT = "v02";
|
||||
process.env.HWLAB_GITOPS_PROFILE = "v02";
|
||||
process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test@127.0.0.1:${dbPort}/hwlab_v02`;
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable";
|
||||
delete process.env.HWLAB_CLOUD_DB_SERVICE_NAME;
|
||||
delete process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE;
|
||||
delete process.env.HWLAB_CLOUD_DB_HOST;
|
||||
delete process.env.HWLAB_CLOUD_DB_PORT;
|
||||
|
||||
const server = createCloudApiServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health`);
|
||||
const payload = await response.json();
|
||||
const dbJson = JSON.stringify(payload.db);
|
||||
assert.equal(payload.environment, "v02");
|
||||
assert.equal(payload.db.environment, "v02");
|
||||
assert.equal(payload.db.secretRefs[0].secretName, "hwlab-cloud-api-v02-db");
|
||||
assert.equal(payload.db.secretRefs[0].secretKey, "database-url");
|
||||
assert.equal(payload.db.optionalPublicDnsAlias.serviceName, "hwlab-v02-postgres");
|
||||
assert.equal(payload.db.optionalPublicDnsAlias.namespace, "hwlab-v02");
|
||||
assert.equal(payload.db.optionalPublicDnsAlias.requiredForReadiness, false);
|
||||
assert.equal(payload.db.optionalPublicDnsAlias.usedForProbe, false);
|
||||
assert.equal(payload.db.safety.environment, "v02");
|
||||
assert.equal(payload.db.safety.devOnly, false);
|
||||
assert.equal(payload.db.redaction.secretMaterialRead, false);
|
||||
assert.equal(dbJson.includes("hwlab-cloud-api-dev-db"), false);
|
||||
assert.equal(dbJson.includes("hwlab-dev"), false);
|
||||
assert.equal(dbJson.includes("127.0.0.1"), false);
|
||||
assert.equal(dbJson.includes(String(dbPort)), false);
|
||||
} finally {
|
||||
for (const [name, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = value;
|
||||
}
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
fakeDb.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health separates DB connected from durable runtime schema readiness", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
const fakeDb = createTcpServer((socket) => socket.end());
|
||||
await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve));
|
||||
const dbPort = fakeDb.address().port;
|
||||
process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test@127.0.0.1:${dbPort}/hwlab`;
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable";
|
||||
|
||||
const server = createCloudApiServer({
|
||||
runtimeStore: {
|
||||
async readiness() {
|
||||
return {
|
||||
adapter: "postgres",
|
||||
durable: false,
|
||||
durableRequested: true,
|
||||
durableCapable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
reason: "test runtime schema is missing",
|
||||
liveRuntimeEvidence: false,
|
||||
fixtureEvidence: false,
|
||||
connection: {
|
||||
queryAttempted: true,
|
||||
queryResult: "schema_blocked",
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true
|
||||
},
|
||||
schema: {
|
||||
checked: true,
|
||||
ready: false,
|
||||
missingTables: ["gateway_sessions"],
|
||||
missingColumns: ["gateway_sessions.gateway_session_json"]
|
||||
},
|
||||
migration: {
|
||||
checked: false,
|
||||
ready: false,
|
||||
missing: true
|
||||
},
|
||||
counts: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.status, "degraded");
|
||||
assert.equal(payload.db.connected, true);
|
||||
assert.equal(payload.db.liveDbEvidence, true);
|
||||
assert.equal(payload.runtime.durable, false);
|
||||
assert.equal(payload.runtime.durableRequested, true);
|
||||
assert.equal(payload.runtime.ready, false);
|
||||
assert.equal(payload.runtime.blocker, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED);
|
||||
assert.equal(payload.readiness.components.db, "ready");
|
||||
assert.equal(payload.readiness.components.runtime, "blocked");
|
||||
assert.equal(payload.readiness.durability.status, "blocked");
|
||||
assert.equal(payload.readiness.durability.dbLiveEvidenceObserved, true);
|
||||
assert.equal(payload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
assert.equal(payload.readiness.durability.blockedLayer, "schema");
|
||||
assert.equal(payload.readiness.durability.queryAttempted, true);
|
||||
assert.equal(payload.readiness.durability.queryResult, "schema_blocked");
|
||||
assert.ok(payload.blockerCodes.includes(RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED));
|
||||
assert.equal(JSON.stringify(payload).includes("password"), false);
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_URL;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_URL = originalUrl;
|
||||
}
|
||||
if (originalSslMode === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
fakeDb.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health keeps DB live evidence separate when durable adapter query is blocked", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
const fakeDb = createTcpServer((socket) => socket.end());
|
||||
await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve));
|
||||
const dbPort = fakeDb.address().port;
|
||||
process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test:password@127.0.0.1:${dbPort}/hwlab`;
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable";
|
||||
|
||||
const server = createCloudApiServer({
|
||||
runtimeStore: {
|
||||
async readiness() {
|
||||
return {
|
||||
adapter: "postgres",
|
||||
durable: false,
|
||||
durableRequested: true,
|
||||
durableCapable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
reason: "test runtime durable read query is blocked",
|
||||
liveRuntimeEvidence: false,
|
||||
fixtureEvidence: false,
|
||||
connection: {
|
||||
queryAttempted: true,
|
||||
queryResult: "query_blocked",
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true,
|
||||
errorCode: "57014"
|
||||
},
|
||||
schema: {
|
||||
checked: true,
|
||||
ready: true,
|
||||
missingTables: [],
|
||||
missingColumns: []
|
||||
},
|
||||
migration: {
|
||||
checked: true,
|
||||
ready: true,
|
||||
missing: false
|
||||
},
|
||||
gates: {
|
||||
auth: { checked: true, ready: true, status: "ready", blocker: null },
|
||||
schema: { checked: true, ready: true, status: "ready", blocker: null },
|
||||
migration: { checked: true, ready: true, status: "ready", blocker: null },
|
||||
durability: {
|
||||
checked: true,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED
|
||||
}
|
||||
},
|
||||
counts: {}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.status, "degraded");
|
||||
assert.equal(payload.db.ready, true);
|
||||
assert.equal(payload.db.connected, true);
|
||||
assert.equal(payload.db.liveDbEvidence, true);
|
||||
assert.equal(payload.runtime.durable, false);
|
||||
assert.equal(payload.runtime.ready, false);
|
||||
assert.equal(payload.runtime.blocker, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED);
|
||||
assert.equal(payload.readiness.components.db, "ready");
|
||||
assert.equal(payload.readiness.components.runtime, "blocked");
|
||||
assert.equal(payload.readiness.durability.status, "blocked");
|
||||
assert.equal(payload.readiness.durability.dbLiveEvidenceObserved, true);
|
||||
assert.equal(payload.readiness.durability.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
assert.equal(payload.readiness.durability.blockedLayer, "durability_query");
|
||||
assert.equal(payload.readiness.durability.queryAttempted, true);
|
||||
assert.equal(payload.readiness.durability.queryResult, "query_blocked");
|
||||
assert.equal(payload.readiness.durability.liveRuntimeEvidence, false);
|
||||
assert.ok(payload.blockerCodes.includes(RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED));
|
||||
assert.equal(payload.readiness.dbDurable.status, "blocked");
|
||||
assert.equal(payload.readiness.dbDurable.blocker, RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED);
|
||||
assert.equal(payload.readiness.codeAgent.currentBlockers.includes(RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED), true);
|
||||
assert.equal(JSON.stringify(payload.db).includes("password"), false);
|
||||
assert.equal(JSON.stringify(payload.db).includes("127.0.0.1"), false);
|
||||
assert.equal(JSON.stringify(payload.db).includes(String(dbPort)), false);
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_URL;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_URL = originalUrl;
|
||||
}
|
||||
if (originalSslMode === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
fakeDb.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health contract distinguishes durable SSL, auth, schema, migration, and query blockers", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
process.env.HWLAB_CLOUD_DB_URL = "redacted";
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = "require";
|
||||
|
||||
const cases = [
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
blockedLayer: "ssl",
|
||||
queryResult: "ssl_negotiation_blocked",
|
||||
gates: {
|
||||
ssl: "blocked",
|
||||
auth: "not_checked",
|
||||
schema: "not_checked",
|
||||
migration: "not_checked",
|
||||
durability: "blocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
blockedLayer: "auth",
|
||||
queryResult: "auth_blocked",
|
||||
gates: {
|
||||
ssl: "ready",
|
||||
auth: "blocked",
|
||||
schema: "not_checked",
|
||||
migration: "not_checked",
|
||||
durability: "blocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
blockedLayer: "schema",
|
||||
queryResult: "schema_blocked",
|
||||
gates: {
|
||||
ssl: "ready",
|
||||
auth: "ready",
|
||||
schema: "blocked",
|
||||
migration: "not_checked",
|
||||
durability: "blocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
blockedLayer: "migration",
|
||||
queryResult: "migration_blocked",
|
||||
gates: {
|
||||
ssl: "ready",
|
||||
auth: "ready",
|
||||
schema: "ready",
|
||||
migration: "blocked",
|
||||
durability: "blocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
blockedLayer: "durability_query",
|
||||
queryResult: "query_blocked",
|
||||
gates: {
|
||||
ssl: "ready",
|
||||
auth: "ready",
|
||||
schema: "ready",
|
||||
migration: "ready",
|
||||
durability: "blocked"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
try {
|
||||
for (const testCase of cases) {
|
||||
const server = createCloudApiServer({
|
||||
dbProbe: {
|
||||
probe: async ({ endpointSource, timeoutMs }) => ({
|
||||
attempted: true,
|
||||
networkAttempted: true,
|
||||
endpointSource,
|
||||
probeType: "tcp-connect",
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true,
|
||||
timeoutMs,
|
||||
durationMs: 0,
|
||||
result: "connected",
|
||||
classification: "tcp_connected",
|
||||
errorCode: null,
|
||||
missingEnv: []
|
||||
})
|
||||
},
|
||||
runtimeStore: {
|
||||
async readiness() {
|
||||
return durableBlockedRuntime(testCase);
|
||||
}
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.status, "degraded");
|
||||
assert.equal(payload.db.ready, true);
|
||||
assert.equal(payload.db.liveDbEvidence, true);
|
||||
assert.equal(payload.runtime.blocker, testCase.blocker);
|
||||
assert.equal(payload.runtime.connection.queryResult, testCase.queryResult);
|
||||
assert.equal(payload.runtime.gates.ssl.status, testCase.gates.ssl);
|
||||
assert.equal(payload.runtime.gates.auth.status, testCase.gates.auth);
|
||||
assert.equal(payload.runtime.gates.schema.status, testCase.gates.schema);
|
||||
assert.equal(payload.runtime.gates.migration.status, testCase.gates.migration);
|
||||
assert.equal(payload.runtime.gates.durability.status, testCase.gates.durability);
|
||||
assert.equal(payload.runtime.durabilityContract.blockedLayer, testCase.blockedLayer);
|
||||
assert.equal(payload.db.runtimeReadiness.blockedLayer, testCase.blockedLayer);
|
||||
assert.equal(payload.db.runtimeReadiness.queryResult, testCase.queryResult);
|
||||
assert.equal(payload.db.runtimeReadiness.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
assert.equal(payload.db.readinessLayers.ssl.status, layerStatus(testCase.gates.ssl));
|
||||
assert.equal(payload.db.readinessLayers.auth.status, layerStatus(testCase.gates.auth));
|
||||
assert.equal(payload.db.readinessLayers.schema.status, layerStatus(testCase.gates.schema));
|
||||
assert.equal(payload.db.readinessLayers.migration.status, layerStatus(testCase.gates.migration));
|
||||
assert.equal(payload.db.readinessLayers.durability.status, layerStatus(testCase.gates.durability));
|
||||
assert.equal(payload.db.readinessLayers[layerForBlockedCase(testCase.blockedLayer)].result, testCase.queryResult);
|
||||
assert.equal(payload.readiness.durability.blockedLayer, testCase.blockedLayer);
|
||||
assert.equal(payload.readiness.durability.queryResult, testCase.queryResult);
|
||||
assert.equal(payload.readiness.durability.secretMaterialRead, false);
|
||||
assert.ok(payload.blockerCodes.includes(testCase.blocker));
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_URL;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_URL = originalUrl;
|
||||
}
|
||||
if (originalSslMode === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health does not treat DB env presence-only as live readiness", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
const originalProbeDisabled = process.env.HWLAB_CLOUD_DB_PROBE_DISABLED;
|
||||
process.env.HWLAB_CLOUD_DB_URL = "postgres://user:password@db.example.invalid:5432/hwlab";
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable";
|
||||
delete process.env.HWLAB_CLOUD_DB_PROBE_DISABLED;
|
||||
|
||||
const server = createCloudApiServer();
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.db.configReady, true);
|
||||
assert.equal(payload.db.connected, false);
|
||||
assert.equal(payload.db.liveConnected, false);
|
||||
assert.equal(payload.db.liveDbEvidence, false);
|
||||
assert.equal(payload.db.safety.liveDbEvidence, false);
|
||||
assert.equal(payload.db.ready, false);
|
||||
assert.equal(payload.db.connectionAttempted, true);
|
||||
assert.equal(payload.db.connectionResult, "forbidden_runtime_host");
|
||||
assert.equal(payload.db.evidence, "live_db_tcp_connection_blocked");
|
||||
assert.equal(payload.db.endpointSource, "secret-url-host");
|
||||
assert.equal(payload.db.connection.endpointSource, "secret-url-host");
|
||||
assert.equal(payload.db.connection.networkAttempted, false);
|
||||
assert.equal(payload.db.connection.classification, "db_url_forbidden_invalid_host");
|
||||
assert.equal(payload.db.readinessLayers.dns.status, "not_proven");
|
||||
assert.equal(JSON.stringify(payload.db).includes("password"), false);
|
||||
assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false);
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_URL;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_URL = originalUrl;
|
||||
}
|
||||
if (originalSslMode === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode;
|
||||
}
|
||||
if (originalProbeDisabled === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_PROBE_DISABLED;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_PROBE_DISABLED = originalProbeDisabled;
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1 describes Code Agent egress blocker without leaking API key", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "https://api.openai.com/v1/responses"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.codeAgent.status, "blocked");
|
||||
assert.equal(payload.codeAgent.egress.directPublicOpenAi, true);
|
||||
assert.match(payload.codeAgent.blocker, /Codex stdio|Codex app-server stdio|long-lived|Codex CLI command|stdio protocol/u);
|
||||
assert.equal(payload.codeAgent.runner.ready, false);
|
||||
assert.equal(payload.codeAgent.safety.secretMaterialRead, false);
|
||||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health reports provider, durable DB, and codex stdio ready when runtime contract passes", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-health-codex-stdio-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
await prepareFakeCodexHome(codexHome);
|
||||
const manager = createCodexStdioSessionManager({
|
||||
createRpcClient: async () => ({
|
||||
async initialize() {
|
||||
return { tools: ["codex", "codex-reply"] };
|
||||
},
|
||||
async listTools() {
|
||||
return ["codex", "codex-reply"];
|
||||
},
|
||||
close() {}
|
||||
})
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspace,
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:17680/v1/responses",
|
||||
HWLAB_CLOUD_DB_URL: "postgres://hwlab_test:password@db.internal.local:5432/hwlab",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "disable"
|
||||
},
|
||||
dbProbe: {
|
||||
probe: async ({ endpointSource, timeoutMs }) => ({
|
||||
attempted: true,
|
||||
networkAttempted: true,
|
||||
endpointSource,
|
||||
probeType: "tcp-connect",
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true,
|
||||
timeoutMs,
|
||||
durationMs: 1,
|
||||
result: "connected",
|
||||
classification: "tcp_connected",
|
||||
errorCode: null,
|
||||
missingEnv: []
|
||||
})
|
||||
},
|
||||
runtimeStore: {
|
||||
async readiness() {
|
||||
return durableReadyRuntime();
|
||||
}
|
||||
},
|
||||
codexStdioManager: manager
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "ok");
|
||||
assert.equal(payload.db.ready, true);
|
||||
assert.equal(payload.db.liveDbEvidence, true);
|
||||
assert.equal(payload.runtime.ready, true);
|
||||
assert.equal(payload.runtime.durable, true);
|
||||
assert.equal(payload.readiness.provider.status, "ready");
|
||||
assert.equal(payload.readiness.provider.ready, true);
|
||||
assert.equal(payload.readiness.provider.blocker, null);
|
||||
assert.equal(payload.readiness.dbDurable.status, "ready");
|
||||
assert.equal(payload.readiness.dbDurable.ready, true);
|
||||
assert.equal(payload.readiness.sessionRunner.status, "codex_stdio_ready");
|
||||
assert.equal(payload.readiness.sessionRunner.ready, true);
|
||||
assert.equal(payload.readiness.sessionRunner.codexStdio, true);
|
||||
assert.equal(payload.readiness.sessionRunner.writeCapable, true);
|
||||
assert.equal(payload.readiness.codexStdio.status, "ready");
|
||||
assert.equal(payload.readiness.codexStdio.ready, true);
|
||||
assert.deepEqual(payload.readiness.codeAgent.currentBlockers, []);
|
||||
assert.equal(payload.readiness.codeAgent.providerReady, true);
|
||||
assert.equal(payload.readiness.codeAgent.durableDbReady, true);
|
||||
assert.equal(payload.readiness.codeAgent.sessionRunnerReady, true);
|
||||
assert.equal(payload.readiness.codeAgent.codexStdioFeasible, true);
|
||||
assert.equal(payload.codeAgent.ready, true);
|
||||
assert.equal(payload.codeAgent.status, "codex-stdio-feasible");
|
||||
assert.equal(payload.codeAgent.longLivedSessionGate.status, "pass");
|
||||
assert.equal(payload.codeAgent.codexStdio.runtimeContract.binary.status, "present");
|
||||
assert.deepEqual(payload.codeAgent.codexStdio.protocol.toolsObserved, []);
|
||||
assert.equal(payload.codeAgent.codexStdio.commandProbe.ready, true);
|
||||
assert.equal(payload.codeAgent.codexStdio.runtimeContract.commandProbe.ready, true);
|
||||
assert.equal(payload.readiness.codexStdio.commandProbeReady, true);
|
||||
assert.equal(JSON.stringify(payload).includes("provider_unavailable"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("dns_resolution_failed"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("runtime_durable_adapter_auth_blocked"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("password"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health blocks full Code Agent readiness when Codex stdio command probe fails", async () => {
|
||||
const workspace = await mkdtemp(path.join(os.tmpdir(), "hwlab-health-codex-stdio-probe-fail-"));
|
||||
const codexHome = path.join(workspace, "codex-home");
|
||||
const workspaceFile = path.join(workspace, "workspace-file");
|
||||
const fakeCodex = await createFakeCodexCommand();
|
||||
await prepareFakeCodexHome(codexHome);
|
||||
await writeFile(workspaceFile, "not a directory\n", "utf8");
|
||||
const manager = createCodexStdioSessionManager({
|
||||
createRpcClient: async () => ({
|
||||
async initialize() {
|
||||
return { tools: ["codex", "codex-reply"] };
|
||||
},
|
||||
async listTools() {
|
||||
return ["codex", "codex-reply"];
|
||||
},
|
||||
close() {}
|
||||
})
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
OPENAI_API_KEY: "test-openai-key-material",
|
||||
CODEX_HOME: codexHome,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
||||
HWLAB_CODE_AGENT_MODEL: "gpt-test",
|
||||
HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command,
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1",
|
||||
HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned",
|
||||
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspaceFile,
|
||||
HWLAB_CODE_AGENT_WORKSPACE: workspaceFile,
|
||||
HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:17680/v1/responses",
|
||||
HWLAB_CLOUD_DB_URL: "postgres://hwlab_test:password@db.internal.local:5432/hwlab",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "disable"
|
||||
},
|
||||
dbProbe: {
|
||||
probe: async ({ endpointSource, timeoutMs }) => ({
|
||||
attempted: true,
|
||||
networkAttempted: true,
|
||||
endpointSource,
|
||||
probeType: "tcp-connect",
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true,
|
||||
timeoutMs,
|
||||
durationMs: 1,
|
||||
result: "connected",
|
||||
classification: "tcp_connected",
|
||||
errorCode: null,
|
||||
missingEnv: []
|
||||
})
|
||||
},
|
||||
runtimeStore: {
|
||||
async readiness() {
|
||||
return durableReadyRuntime();
|
||||
}
|
||||
},
|
||||
codexStdioManager: manager
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "degraded");
|
||||
assert.equal(payload.ready, false);
|
||||
assert.equal(payload.readiness.components.codeAgent, "blocked");
|
||||
assert.equal(payload.readiness.codexStdio.status, "blocked");
|
||||
assert.equal(payload.readiness.codexStdio.ready, false);
|
||||
assert.equal(payload.readiness.codexStdio.commandProbeReady, false);
|
||||
assert.ok(payload.readiness.codexStdio.blockerCodes.includes("codex_stdio_command_probe_failed"));
|
||||
assert.ok(payload.blockerCodes.includes("codex_stdio_command_probe_failed"));
|
||||
assert.equal(payload.codeAgent.ready, false);
|
||||
assert.equal(payload.codeAgent.codexStdio.commandProbe.ready, false);
|
||||
assert.equal(payload.codeAgent.codexStdio.commandProbe.status, "blocked");
|
||||
assert.equal(JSON.stringify(payload).includes("test-openai-key-material"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("password"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
await rm(workspace, { recursive: true, force: true });
|
||||
await rm(fakeCodex.root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api REST JSON-RPC bridge exposes unknown serviceId reason for Code Agent internal calls", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio"
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/rpc/hardware.invoke.shell`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_server-test-invalid-service-id",
|
||||
"x-source-service-id": "hwlab-code-agent-hotfix"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
projectId: "prj_mvp_topology",
|
||||
gatewaySessionId: "gws_DESKTOP-1MHOD9I",
|
||||
resourceId: "res_windows_host",
|
||||
capabilityId: "cap_windows_cmd_exec",
|
||||
input: {
|
||||
command: "cmd /c echo should-not-dispatch"
|
||||
}
|
||||
})
|
||||
});
|
||||
assert.equal(response.status, 400);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.error.code, -32600);
|
||||
assert.equal(payload.error.message, "Invalid JSON-RPC request");
|
||||
assert.match(payload.error.data.reason, /unknown serviceId "hwlab-code-agent-hotfix"/u);
|
||||
assert.equal(JSON.stringify(payload).includes("should-not-dispatch"), false);
|
||||
assert.equal(JSON.stringify(payload).includes("sk-"), false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
|
||||
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||
|
||||
export function readBody(request, limitBytes = DEFAULT_BODY_LIMIT_BYTES) {
|
||||
const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_BODY_LIMIT_BYTES;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
request.setEncoding("utf8");
|
||||
|
||||
request.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (Buffer.byteLength(body, "utf8") > limit) {
|
||||
request.destroy(new Error(`request body exceeds ${limit} bytes`));
|
||||
}
|
||||
});
|
||||
|
||||
request.on("end", () => resolve(body));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function sendJson(response, statusCode, body) {
|
||||
response.writeHead(statusCode, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store"
|
||||
});
|
||||
response.end(JSON.stringify(body) + "\n");
|
||||
}
|
||||
|
||||
export function getHeader(request, name) {
|
||||
const value = request.headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) return value[0];
|
||||
return value;
|
||||
}
|
||||
|
||||
export function firstHeaderValue(request, name) {
|
||||
return getHeader(request, name);
|
||||
}
|
||||
|
||||
export function truthyFlag(value) {
|
||||
return /^(?:1|true|yes|on|enabled)$/iu.test(String(value ?? "").trim());
|
||||
}
|
||||
|
||||
export function safeTraceId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
export function safeSessionId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
export function safeConversationId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^cnv_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
export function safeOpaqueId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
export function parsePositiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
export function positiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.ts";
|
||||
|
||||
test("cloud api aggregates live HWLAB build times from health and controlled deploy/catalog metadata", async () => {
|
||||
const healthByPath = new Map([
|
||||
["/hwlab-cloud-web/health/live", {
|
||||
serviceId: "hwlab-cloud-web",
|
||||
status: "ok",
|
||||
revision: "webabcdef123456",
|
||||
commit: { id: "webabcdef123456", source: "test" },
|
||||
image: {
|
||||
reference: "127.0.0.1:5000/hwlab/hwlab-cloud-web:webabcd",
|
||||
tag: "webabcd",
|
||||
digest: "sha256:" + "1".repeat(64)
|
||||
},
|
||||
build: {
|
||||
createdAt: "2026-05-23T02:10:00.000Z",
|
||||
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
|
||||
}
|
||||
}],
|
||||
["/hwlab-agent-mgr/health/live", {
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
status: "ok",
|
||||
revision: "48dfbf9",
|
||||
commit: { id: "48dfbf9", source: "test" },
|
||||
image: {
|
||||
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
|
||||
tag: "48dfbf9",
|
||||
digest: "sha256:" + "2".repeat(64)
|
||||
}
|
||||
}],
|
||||
["/hwlab-agent-skills/health/live", {
|
||||
serviceId: "hwlab-agent-skills",
|
||||
status: "ok",
|
||||
revision: "skillsabcdef123456",
|
||||
commit: { id: "skillsabcdef123456", source: "test" },
|
||||
image: {
|
||||
reference: "127.0.0.1:5000/hwlab/hwlab-agent-skills:skillsabcd",
|
||||
tag: "skillsabcd",
|
||||
digest: "sha256:" + "5".repeat(64)
|
||||
}
|
||||
}],
|
||||
["/external/health", {
|
||||
serviceId: "postgres",
|
||||
status: "ok",
|
||||
image: {
|
||||
reference: "postgres:16",
|
||||
tag: "16"
|
||||
}
|
||||
}]
|
||||
]);
|
||||
const liveBuildMetadata = {
|
||||
deployManifest: {
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-cloud-web",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-cloud-web:webcat",
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
replicas: 1
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:3df89fe",
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
replicas: 1
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-agent-skills",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-agent-skills:skillsabcd",
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
replicas: 1,
|
||||
env: {
|
||||
HWLAB_COMMIT_ID: "skillsabcdef123456",
|
||||
HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-agent-skills:skillsabcd",
|
||||
HWLAB_IMAGE_TAG: "skillsabcd",
|
||||
HWLAB_BUILD_CREATED_AT: "2026-05-23T05:30:00.000Z",
|
||||
HWLAB_BUILD_SOURCE: "deploy-env-test"
|
||||
}
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-agent-worker",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-agent-worker:workcat",
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
replicas: 0
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-edge-proxy",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-edge-proxy:edgecat",
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health",
|
||||
replicas: 1
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-extra-lab",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-extra-lab:extracat",
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
replicas: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
artifactCatalog: {
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
commitId: "3df89fe",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:3df89fe",
|
||||
imageTag: "3df89fe",
|
||||
digest: "sha256:" + "3".repeat(64),
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
buildCreatedAt: "2026-05-23T04:20:00.000Z",
|
||||
buildSource: "artifact-catalog-test"
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-agent-skills",
|
||||
commitId: "skillsabcdef123456",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-agent-skills:skillsabcd",
|
||||
imageTag: "skillsabcd",
|
||||
digest: "sha256:" + "5".repeat(64),
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
buildCreatedAt: null,
|
||||
buildSource: null
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-extra-lab",
|
||||
commitId: "extracatabcdef123456",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-extra-lab:extracat",
|
||||
imageTag: "extracat",
|
||||
digest: "sha256:" + "4".repeat(64),
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
buildCreatedAt: "2026-05-23T03:00:00.000Z",
|
||||
buildSource: "artifact-catalog-test"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_COMMIT_ID: "apiabcdef123456",
|
||||
HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-cloud-api:apiabcd",
|
||||
HWLAB_IMAGE_TAG: "apiabcd",
|
||||
HWLAB_BUILD_CREATED_AT: "2026-05-23T01:00:00.000Z",
|
||||
HWLAB_CLOUD_WEB_SERVICE_URL: "http://live.test/hwlab-cloud-web",
|
||||
HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr",
|
||||
HWLAB_AGENT_WORKER_URL: "http://live.test/missing-agent-worker",
|
||||
HWLAB_GATEWAY_URL: "http://live.test/missing-gateway",
|
||||
HWLAB_GATEWAY_SIMU_URL: "http://live.test/missing-gateway-simu",
|
||||
HWLAB_BOX_SIMU_URL: "http://live.test/missing-box-simu",
|
||||
HWLAB_PATCH_PANEL_URL: "http://live.test/missing-patch-panel",
|
||||
HWLAB_ROUTER_URL: "http://live.test/missing-router",
|
||||
HWLAB_TUNNEL_CLIENT_URL: "http://live.test/missing-tunnel-client",
|
||||
HWLAB_EDGE_PROXY_URL: "http://live.test/external",
|
||||
HWLAB_CLI_URL: "http://live.test/missing-cli",
|
||||
HWLAB_AGENT_SKILLS_URL: "http://live.test/hwlab-agent-skills",
|
||||
HWLAB_EXTRA_LAB_URL: "http://live.test/missing-extra-lab"
|
||||
},
|
||||
liveBuildMetadata,
|
||||
fetchImpl: async (url) => {
|
||||
const path = new URL(url).pathname;
|
||||
const payload = healthByPath.get(path);
|
||||
if (!payload) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
async text() {
|
||||
return JSON.stringify({ error: "offline" });
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/live-builds`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.contractVersion, "live-builds-v1");
|
||||
assert.equal(payload.latest.serviceId, "hwlab-agent-skills");
|
||||
assert.equal(payload.latest.build.createdAt, "2026-05-23T05:30:00.000Z");
|
||||
assert.equal(payload.latest.image.tag, "skillsabcd");
|
||||
assert.equal(payload.latest.commit.id, "skillsabcdef123456");
|
||||
assert.equal(payload.latest.build.metadataSource, "deploy-env:HWLAB_BUILD_CREATED_AT");
|
||||
assert.equal(payload.latest.build.liveMetadataMatch.metadata.imageTag, "skillsabcd");
|
||||
assert.match(payload.latest.build.liveHealthMissingReason, /匹配的 deploy metadata/u);
|
||||
assert.equal(payload.counts.total, payload.services.length);
|
||||
assert.equal(payload.counts.external, 2);
|
||||
assert.equal(payload.counts.withBuildTime, 3);
|
||||
const manager = payload.services.find((service) => service.serviceId === "hwlab-agent-mgr");
|
||||
assert.equal(manager.build.createdAt, null);
|
||||
assert.equal(manager.image.tag, "48dfbf9");
|
||||
assert.equal(manager.revision, "48dfbf9");
|
||||
assert.equal(manager.build.metadataSource, "live-health:controlled-metadata-mismatch");
|
||||
assert.equal(manager.build.unavailableReason, "构建时间不可用:live tag 与 catalog metadata 不匹配");
|
||||
assert.match(manager.build.unavailableDetail, /live tag 48dfbf9/u);
|
||||
assert.match(manager.build.unavailableDetail, /metadata tag 3df89fe/u);
|
||||
const extra = payload.services.find((service) => service.serviceId === "hwlab-extra-lab");
|
||||
assert.equal(extra.build.createdAt, null);
|
||||
assert.match(extra.build.unavailableReason, /当前 live health 不可用/u);
|
||||
const worker = payload.services.find((service) => service.serviceId === "hwlab-agent-worker");
|
||||
assert.equal(worker.build.createdAt, null);
|
||||
assert.match(worker.build.unavailableReason, /构建时间不可用:当前 live health 不可用/u);
|
||||
assert.match(worker.build.unavailableReason, /hwlab-agent-worker 当前 hwlab-dev desired replicas=0/u);
|
||||
assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-edge-proxy" && service.healthUrl.endsWith("/health")));
|
||||
assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-frpc" && service.image.tag === "v0.68.1"));
|
||||
assert.ok(payload.services.some((service) => service.serviceId === "hwlab-device-pod"));
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api ignores old repository reports and keeps missing buildCreatedAt unavailable", async () => {
|
||||
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "hwlab-live-builds-"));
|
||||
const deployDir = path.join(tempRoot, "deploy");
|
||||
const oldReportDir = path.join(tempRoot, "reports", "dev-gate");
|
||||
await mkdir(deployDir, { recursive: true });
|
||||
await mkdir(oldReportDir, { recursive: true });
|
||||
await writeFile(path.join(deployDir, "deploy.json"), `${JSON.stringify({
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
replicas: 1
|
||||
}
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
await writeFile(path.join(deployDir, "artifact-catalog.dev.json"), `${JSON.stringify({
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
commitId: "48dfbf9abcdef",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
|
||||
imageTag: "48dfbf9",
|
||||
digest: "sha256:" + "6".repeat(64),
|
||||
namespace: "hwlab-dev",
|
||||
profile: "dev",
|
||||
healthPath: "/health/live",
|
||||
buildCreatedAt: null,
|
||||
buildSource: null
|
||||
}
|
||||
]
|
||||
}, null, 2)}\n`);
|
||||
await writeFile(path.join(oldReportDir, "dev-artifacts.json"), `${JSON.stringify({
|
||||
artifactPublish: {
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
sourceCommitId: "48dfbf9abcdef",
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
|
||||
imageTag: "48dfbf9",
|
||||
digest: "sha256:" + "6".repeat(64),
|
||||
buildCreatedAt: "2099-01-01T00:00:00.000Z",
|
||||
buildSource: "old-report-must-not-be-read"
|
||||
}
|
||||
]
|
||||
}
|
||||
}, null, 2)}\n`);
|
||||
|
||||
const observe = async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr"
|
||||
},
|
||||
repoRoot: tempRoot,
|
||||
fetchImpl: async (url) => {
|
||||
if (new URL(url).pathname === "/hwlab-agent-mgr/health/live") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify({
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
status: "ok",
|
||||
revision: "48dfbf9abcdef",
|
||||
commit: { id: "48dfbf9abcdef", source: "test" },
|
||||
image: {
|
||||
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
|
||||
tag: "48dfbf9",
|
||||
digest: "sha256:" + "6".repeat(64)
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
async text() {
|
||||
return JSON.stringify({ error: "offline" });
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/live-builds`);
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const withOldReport = await observe();
|
||||
await rm(path.join(tempRoot, "reports"), { recursive: true, force: true });
|
||||
const withoutOldReport = await observe();
|
||||
const summarize = (payload) => {
|
||||
const manager = payload.services.find((service) => service.serviceId === "hwlab-agent-mgr");
|
||||
return {
|
||||
latest: payload.latest,
|
||||
source: payload.source,
|
||||
createdAt: manager.build.createdAt,
|
||||
metadataSource: manager.build.metadataSource,
|
||||
unavailableReason: manager.build.unavailableReason,
|
||||
unavailableDetail: manager.build.unavailableDetail,
|
||||
liveMetadataMatch: manager.build.liveMetadataMatch,
|
||||
counts: payload.counts
|
||||
};
|
||||
};
|
||||
|
||||
assert.deepEqual(summarize(withOldReport), summarize(withoutOldReport));
|
||||
const manager = withOldReport.services.find((service) => service.serviceId === "hwlab-agent-mgr");
|
||||
assert.equal(withOldReport.latest, null);
|
||||
assert.equal(manager.build.createdAt, null);
|
||||
assert.equal(manager.build.metadataSource, "live-health:matched-controlled-metadata-without-build-time");
|
||||
assert.equal(manager.build.unavailableReason, "构建时间不可用:当前 live 镜像未提供 buildCreatedAt");
|
||||
assert.match(manager.build.unavailableDetail, /catalog metadata/u);
|
||||
assert.equal(Object.hasOwn(withOldReport.source, "artifactReportSource"), false);
|
||||
assert.equal(JSON.stringify(withOldReport).includes("2099-01-01T00:00:00.000Z"), false);
|
||||
assert.equal(JSON.stringify(withOldReport).includes("artifact-report"), false);
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.ts";
|
||||
import { createCloudRuntimeStore } from "../db/runtime-store.ts";
|
||||
import { createGreenM3StatusRuntimeStore, m3ReadinessRequestJson } from "./server-test-helpers.ts";
|
||||
|
||||
test("cloud api /v1 exposes M3 IO control contract without generic frontend hardware RPC", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
HWLAB_M3_IO_CONTROL_ENABLED: "true",
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
m3IoRequestJson: m3ReadinessRequestJson
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const index = await fetch(`http://127.0.0.1:${port}/v1`);
|
||||
assert.equal(index.status, 200);
|
||||
const indexPayload = await index.json();
|
||||
assert.equal(indexPayload.m3IoControl.route, "/v1/m3/io");
|
||||
assert.equal(indexPayload.m3IoControl.boundaries.directFrontendGatewayOrBoxAccess, false);
|
||||
assert.ok(indexPayload.methods.includes("m3.io.do.write"));
|
||||
assert.ok(indexPayload.methods.includes("m3.io.di.read"));
|
||||
|
||||
const contract = await fetch(`http://127.0.0.1:${port}/v1/m3/io`);
|
||||
assert.equal(contract.status, 200);
|
||||
const payload = await contract.json();
|
||||
assert.equal(payload.status, "available");
|
||||
assert.equal(payload.controlReady, true);
|
||||
assert.equal(payload.readiness.status, "ready");
|
||||
assert.equal(payload.chain.sourceResourceId, "res_boxsimu_1");
|
||||
assert.equal(payload.chain.targetResourceId, "res_boxsimu_2");
|
||||
assert.equal(payload.boundaries.frontendCallsOnly, "/v1/m3/io");
|
||||
assert.equal(payload.boundaries.patchPanelOwnsPropagation, true);
|
||||
assert.equal(payload.boundaries.genericHardwareRpcExposedToFrontend, false);
|
||||
|
||||
const statusResponse = await fetch(`http://127.0.0.1:${port}/v1/m3/status`);
|
||||
assert.equal(statusResponse.status, 200);
|
||||
const statusPayload = await statusResponse.json();
|
||||
assert.equal(statusPayload.route, "/v1/m3/status");
|
||||
assert.equal(statusPayload.chain.sourceResourceId, "res_boxsimu_1");
|
||||
assert.equal(statusPayload.chain.targetResourceId, "res_boxsimu_2");
|
||||
assert.equal(statusPayload.gateways.length, 2);
|
||||
assert.equal(statusPayload.gateways.every((gateway) => gateway.online === true), true);
|
||||
assert.equal(statusPayload.boxes.find((box) => box.resourceId === "res_boxsimu_1").ports.DO1.value, false);
|
||||
assert.equal(statusPayload.boxes.find((box) => box.resourceId === "res_boxsimu_2").ports.DI1.value, false);
|
||||
assert.equal(statusPayload.patchPanel.connectionActive, true);
|
||||
assert.equal(statusPayload.io.status, "live");
|
||||
assert.equal(statusPayload.io.do1.resourceId, "res_boxsimu_1");
|
||||
assert.equal(statusPayload.io.do1.port, "DO1");
|
||||
assert.equal(statusPayload.io.do1.value, false);
|
||||
assert.equal(statusPayload.io.do1.sourceKind, "DEV-LIVE");
|
||||
assert.equal(statusPayload.io.di1.resourceId, "res_boxsimu_2");
|
||||
assert.equal(statusPayload.io.di1.port, "DI1");
|
||||
assert.equal(statusPayload.io.di1.value, false);
|
||||
assert.equal(statusPayload.io.di1.sourceKind, "DEV-LIVE");
|
||||
assert.equal(statusPayload.io.patchPanel.serviceId, "hwlab-patch-panel");
|
||||
assert.equal(statusPayload.io.patchPanel.connectionActive, true);
|
||||
assert.equal(statusPayload.operation.status, "blocked");
|
||||
assert.equal(statusPayload.operation.operationId, null);
|
||||
assert.equal(statusPayload.operation.blocker, "runtime_durable_adapter_missing");
|
||||
assert.equal(statusPayload.trace.status, "blocked");
|
||||
assert.equal(statusPayload.trace.traceId, null);
|
||||
assert.equal(statusPayload.trace.blocker, "runtime_durable_adapter_missing");
|
||||
assert.equal(statusPayload.audit.status, "blocked");
|
||||
assert.equal(statusPayload.audit.auditId, null);
|
||||
assert.equal(statusPayload.audit.blocker, "runtime_durable_adapter_missing");
|
||||
assert.equal(statusPayload.runtimeDurable.status, "blocked");
|
||||
assert.equal(statusPayload.runtimeDurable.green, false);
|
||||
assert.equal(statusPayload.runtimeDurable.blocker, "runtime_durable_adapter_missing");
|
||||
assert.equal(statusPayload.runtimeDurable.readStatus.audit, "read");
|
||||
assert.equal(statusPayload.runtimeDurable.readStatus.evidence, "read");
|
||||
assert.equal(statusPayload.runtimeDurable.auditReady, true);
|
||||
assert.equal(statusPayload.runtimeDurable.evidenceReady, true);
|
||||
assert.equal(statusPayload.trust.durableStatus, "blocked");
|
||||
assert.notEqual(statusPayload.trust.durableStatus, "green");
|
||||
assert.equal(statusPayload.trust.runtime.blocker, "runtime_durable_adapter_missing");
|
||||
assert.equal(statusPayload.boundaries.frontendCallsOnly, "/v1/m3/status");
|
||||
assert.equal(statusPayload.boundaries.directFrontendGatewayOrBoxAccess, false);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/m3/status clears nested runtime blocker when trusted durable evidence is green", async () => {
|
||||
const server = createCloudApiServer({
|
||||
runtimeStore: createGreenM3StatusRuntimeStore({ blocker: "runtime_durable_not_green" }),
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
m3IoRequestJson: m3ReadinessRequestJson
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const statusResponse = await fetch(`http://127.0.0.1:${port}/v1/m3/status`);
|
||||
assert.equal(statusResponse.status, 200);
|
||||
const statusPayload = await statusResponse.json();
|
||||
|
||||
assert.equal(statusPayload.contractVersion, "m3-status-v1");
|
||||
assert.equal(statusPayload.status, "live");
|
||||
assert.equal(statusPayload.sourceKind, "DEV-LIVE");
|
||||
assert.equal(statusPayload.patchPanel.connectionActive, true);
|
||||
assert.equal(statusPayload.trust.durableStatus, "green");
|
||||
assert.equal(statusPayload.trust.blocker, null);
|
||||
assert.equal(statusPayload.trust.runtime.status, "ready");
|
||||
assert.equal(statusPayload.trust.runtime.blocker, null);
|
||||
assert.equal(statusPayload.trust.operationId, "op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f");
|
||||
assert.equal(statusPayload.trust.traceId, "trc_hotfix_307_verify_1779527747043_read_false");
|
||||
assert.equal(statusPayload.trust.auditId, "aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded");
|
||||
assert.equal(statusPayload.trust.evidenceId, "evd_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f_succeeded");
|
||||
assert.equal(statusPayload.operation.status, "read");
|
||||
assert.equal(statusPayload.operation.operationId, "op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f");
|
||||
assert.equal(statusPayload.operation.blocker, null);
|
||||
assert.equal(statusPayload.trace.status, "read");
|
||||
assert.equal(statusPayload.trace.traceId, "trc_hotfix_307_verify_1779527747043_read_false");
|
||||
assert.equal(statusPayload.trace.blocker, null);
|
||||
assert.equal(statusPayload.audit.status, "read");
|
||||
assert.equal(statusPayload.audit.auditId, "aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded");
|
||||
assert.equal(statusPayload.audit.blocker, null);
|
||||
assert.equal(statusPayload.runtimeDurable.status, "green");
|
||||
assert.equal(statusPayload.runtimeDurable.green, true);
|
||||
assert.equal(statusPayload.runtimeDurable.blocker, null);
|
||||
assert.equal(statusPayload.runtimeDurable.auditReady, true);
|
||||
assert.equal(statusPayload.runtimeDurable.evidenceReady, true);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/diagnostics/gate exposes Chinese single-table rows from live backend aggregation", async () => {
|
||||
const runtimeStore = createCloudRuntimeStore({
|
||||
now: () => "2026-05-23T00:00:00.000Z"
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
runtimeStore,
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel",
|
||||
HWLAB_COMMIT_ID: "abc123456789",
|
||||
HWLAB_IMAGE_TAG: "abc1234"
|
||||
},
|
||||
m3IoRequestJson: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
body: {
|
||||
error: {
|
||||
message: "gateway offline"
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/diagnostics/gate`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.route, "/v1/diagnostics/gate");
|
||||
assert.equal(payload.contractVersion, "gate-diagnostics-table-v1");
|
||||
assert.equal(payload.sourceKind, "LIVE-BACKEND");
|
||||
assert.equal(payload.liveBackend, true);
|
||||
assert.deepEqual(payload.columns, ["类别", "检查项", "状态", "归因对象", "关键细节", "证据/trace", "更新时间", "下一步"]);
|
||||
assert.equal(payload.safety.frontendHardcodedRows, false);
|
||||
assert.equal(payload.safety.sourceFixturePromoted, false);
|
||||
assert.equal(payload.safety.blockedIsGreen, false);
|
||||
assert.equal(payload.safety.hardwareControlSurface, false);
|
||||
assert.equal(payload.safety.codeAgentConversationSurface, false);
|
||||
assert.ok(payload.rows.length >= 6);
|
||||
assert.ok(payload.rows.every((row) => row.sourceKind === "LIVE-BACKEND"));
|
||||
assert.ok(payload.rows.every((row) => ["通过", "阻塞", "失败", "待验证", "信息"].includes(row.status)));
|
||||
assert.ok(payload.rows.some((row) => row.category === "M3" && row.status === "阻塞"));
|
||||
assert.ok(payload.rows.some((row) => row.check === "/health/live readiness"));
|
||||
assert.ok(payload.rows.some((row) => row.check === "audit.event.query"));
|
||||
assert.ok(payload.rows.some((row) => row.check === "evidence.record.query"));
|
||||
assert.ok(payload.rows.some((row) => row.check === "Cloud API 实况身份" && row.status === "通过"));
|
||||
assert.equal(
|
||||
payload.rows.some((row) => row.status === "阻塞" && row.statusKey === "pass"),
|
||||
false,
|
||||
"blocked rows must not be mapped to pass"
|
||||
);
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api /v1/m3/io returns structured blocked payload when gateway is unavailable", async () => {
|
||||
const runtimeStore = createCloudRuntimeStore({
|
||||
now: () => "2026-05-23T00:00:00.000Z"
|
||||
});
|
||||
const server = createCloudApiServer({
|
||||
runtimeStore,
|
||||
env: {
|
||||
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
|
||||
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
|
||||
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
|
||||
},
|
||||
m3IoRequestJson: async () => ({
|
||||
ok: false,
|
||||
status: 503,
|
||||
body: {
|
||||
error: {
|
||||
message: "gateway offline"
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/m3/io`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trace-id": "trc_http_m3_gateway_missing",
|
||||
"x-actor-id": "usr_http_m3"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
action: "do.write",
|
||||
value: true
|
||||
})
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.status, "blocked");
|
||||
assert.equal(payload.accepted, false);
|
||||
assert.equal(payload.route, "/v1/m3/io");
|
||||
assert.equal(payload.method, "POST");
|
||||
assert.equal(payload.traceId, "trc_http_m3_gateway_missing");
|
||||
assert.equal(payload.operationId, null);
|
||||
assert.equal(payload.auditId, null);
|
||||
assert.equal(payload.evidenceId, null);
|
||||
assert.equal(payload.audit.status, "not_written");
|
||||
assert.equal(payload.evidence.status, "blocked");
|
||||
assert.equal(payload.readback, null);
|
||||
assert.match(payload.blocker.zh, /gateway 未注册\/不可用/u);
|
||||
assert.equal(payload.controlPath.cloudApi, true);
|
||||
assert.equal(payload.controlPath.frontendBypass, false);
|
||||
assert.equal(payload.evidenceState.sourceKind, "BLOCKED");
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
|
||||
import { ERROR_CODES } from "../protocol/index.mjs";
|
||||
import { M3_IO_CONTROL_ROUTE, handleM3IoControl } from "./m3-io-control.ts";
|
||||
import { getHeader, readBody, sendJson } from "./server-http-utils.ts";
|
||||
|
||||
export async function handleM3IoControlHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(response, 400, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: "m3-io-control-v1",
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
operationId: null,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
audit: {
|
||||
auditId: null,
|
||||
status: "not_written"
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: null,
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED"
|
||||
},
|
||||
readback: null,
|
||||
error: {
|
||||
code: "parse_error",
|
||||
message: "Invalid JSON body",
|
||||
reason: error.message
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
sendJson(response, 400, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: "m3-io-control-v1",
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
traceId: getHeader(request, "x-trace-id") || "trc_unassigned",
|
||||
operationId: null,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
audit: {
|
||||
auditId: null,
|
||||
status: "not_written"
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: null,
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED"
|
||||
},
|
||||
readback: null,
|
||||
error: {
|
||||
code: "invalid_params",
|
||||
message: "M3 IO control body must be a JSON object"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await handleM3IoControl(
|
||||
{
|
||||
...params,
|
||||
traceId: getHeader(request, "x-trace-id") || params.traceId,
|
||||
requestId: getHeader(request, "x-request-id") || params.requestId,
|
||||
actorId: getHeader(request, "x-actor-id") || params.actorId
|
||||
},
|
||||
{
|
||||
runtimeStore: options.runtimeStore,
|
||||
env: options.env,
|
||||
requestJson: options.m3IoRequestJson,
|
||||
traceId: getHeader(request, "x-trace-id"),
|
||||
requestId: getHeader(request, "x-request-id"),
|
||||
actorId: getHeader(request, "x-actor-id")
|
||||
}
|
||||
);
|
||||
sendJson(response, payload.httpStatus ?? 200, payload);
|
||||
} catch (error) {
|
||||
const protocolCode = Number.isInteger(error?.code) ? error.code : ERROR_CODES.internalError;
|
||||
sendJson(response, protocolCode === ERROR_CODES.invalidParams ? 400 : 200, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
contractVersion: "m3-io-control-v1",
|
||||
route: M3_IO_CONTROL_ROUTE,
|
||||
method: "POST",
|
||||
status: "blocked",
|
||||
accepted: false,
|
||||
traceId: getHeader(request, "x-trace-id") || params.traceId || "trc_unassigned",
|
||||
operationId: null,
|
||||
auditId: null,
|
||||
evidenceId: null,
|
||||
audit: {
|
||||
auditId: null,
|
||||
status: "not_written"
|
||||
},
|
||||
evidence: {
|
||||
evidenceId: null,
|
||||
status: "blocked",
|
||||
sourceKind: "BLOCKED"
|
||||
},
|
||||
readback: null,
|
||||
error: {
|
||||
code: protocolCode,
|
||||
message: error.message,
|
||||
data: error.data ?? {}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { buildMetadataFromEnv, imageTagFromReference, normalizeIsoTimestamp } from "../build-metadata.mjs";
|
||||
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../protocol/index.mjs";
|
||||
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
|
||||
import { buildDevicePodRestPayload, buildDevicePodStatus } from "../device-pod/fake-data.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const LIVE_BUILD_TIMEOUT_MS = 900;
|
||||
const LIVE_BUILD_METADATA_PATHS = Object.freeze({
|
||||
deployManifest: "deploy/deploy.json",
|
||||
artifactCatalog: "deploy/artifact-catalog.dev.json"
|
||||
});
|
||||
const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
|
||||
"hwlab-cloud-api": Object.freeze({ urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
|
||||
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
|
||||
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
|
||||
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411" }),
|
||||
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL", defaultUrl: "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" }),
|
||||
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001" }),
|
||||
"hwlab-gateway-simu": Object.freeze({ urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
|
||||
"hwlab-box-simu": Object.freeze({ urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
|
||||
"hwlab-patch-panel": Object.freeze({ urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }),
|
||||
"hwlab-router": Object.freeze({ urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }),
|
||||
"hwlab-tunnel-client": Object.freeze({ urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }),
|
||||
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
|
||||
"hwlab-cli": Object.freeze({ urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501" }),
|
||||
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
|
||||
});
|
||||
const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
|
||||
Object.freeze({
|
||||
serviceId: "hwlab-frpc",
|
||||
serviceName: "hwlab-frpc",
|
||||
kind: "external",
|
||||
externalImage: true,
|
||||
defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1",
|
||||
externalReason: "外部镜像或非 HWLAB 构建产物"
|
||||
})
|
||||
]);
|
||||
|
||||
function defaultRuntimeEnvironment(env = process.env) {
|
||||
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV;
|
||||
}
|
||||
|
||||
export async function buildLiveBuildsPayload(options = {}, dependencies = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const metadata = await loadLiveBuildMetadata(options);
|
||||
const inventory = liveBuildServiceInventory(metadata);
|
||||
const services = await Promise.all(
|
||||
inventory.map((service) => observeLiveBuildService(service, { ...options, env, buildHealthPayload: dependencies.buildHealthPayload }))
|
||||
);
|
||||
const hwlabServices = services.filter((service) => service.kind === "hwlab");
|
||||
const latest = [...hwlabServices]
|
||||
.filter((service) => service.build.createdAt)
|
||||
.sort((left, right) => Date.parse(right.build.createdAt) - Date.parse(left.build.createdAt))[0] ?? null;
|
||||
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: (dependencies.runtimeEnvironment ?? defaultRuntimeEnvironment)(env),
|
||||
status: "ok",
|
||||
contractVersion: "live-builds-v1",
|
||||
observedAt: new Date().toISOString(),
|
||||
source: {
|
||||
kind: "live-health+deploy-artifact-catalog",
|
||||
route: "/v1/live-builds",
|
||||
healthPath: "/health/live",
|
||||
metadataPaths: LIVE_BUILD_METADATA_PATHS,
|
||||
desiredStateSource: metadata.deployManifest.status === "ok" ? LIVE_BUILD_METADATA_PATHS.deployManifest : "unavailable",
|
||||
artifactCatalogSource: metadata.artifactCatalog.status === "ok" ? LIVE_BUILD_METADATA_PATHS.artifactCatalog : "unavailable",
|
||||
note: "Each row prefers current live service health build metadata and falls back only to deploy/artifact catalog metadata when the live tag/revision matches; observedAt is not used as build time."
|
||||
},
|
||||
latest,
|
||||
counts: {
|
||||
total: services.length,
|
||||
hwlab: hwlabServices.length,
|
||||
withBuildTime: hwlabServices.filter((service) => Boolean(service.build.createdAt)).length,
|
||||
unavailable: hwlabServices.filter((service) => service.build.createdAt === null).length,
|
||||
external: services.filter((service) => service.kind === "external").length
|
||||
},
|
||||
services
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildDevicePodCloudApiPayload(url, options = {}) {
|
||||
const upstreamPayload = await fetchDevicePodUpstreamPayload(url, options);
|
||||
if (upstreamPayload) {
|
||||
return {
|
||||
...upstreamPayload,
|
||||
cloudApiProxy: {
|
||||
route: url.pathname,
|
||||
upstream: configuredDevicePodUrl(options.env ?? process.env),
|
||||
status: "proxied"
|
||||
}
|
||||
};
|
||||
}
|
||||
const fallback = buildDevicePodRestPayload(url.pathname, url.searchParams, {
|
||||
sourceKind: "FAKE-CLOUD-API"
|
||||
}) ?? buildDevicePodStatus(undefined, { sourceKind: "FAKE-CLOUD-API" });
|
||||
return {
|
||||
...fallback,
|
||||
cloudApiProxy: {
|
||||
route: url.pathname,
|
||||
upstream: configuredDevicePodUrl(options.env ?? process.env),
|
||||
status: "fake-fallback",
|
||||
reason: "hwlab-device-pod upstream is unavailable or not configured"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDevicePodUpstreamPayload(url, options = {}) {
|
||||
const baseUrl = configuredDevicePodUrl(options.env ?? process.env);
|
||||
if (!baseUrl) return null;
|
||||
const target = `${baseUrl}${url.pathname}${url.search}`;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 900);
|
||||
try {
|
||||
const response = await (options.fetchImpl ?? fetch)(target, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function configuredDevicePodUrl(env = process.env) {
|
||||
const value = String(env.HWLAB_DEVICE_POD_URL ?? "").trim();
|
||||
return value ? value.replace(/\/+$/u, "") : "";
|
||||
}
|
||||
|
||||
async function observeLiveBuildService(service, options) {
|
||||
if (service.externalImage) {
|
||||
return externalLiveBuildRecord(service, {
|
||||
healthUrl: null,
|
||||
httpStatus: null,
|
||||
imageReference: service.defaultImage ?? "unknown",
|
||||
reason: "外部镜像或非 HWLAB 构建产物"
|
||||
});
|
||||
}
|
||||
|
||||
const metadataRecords = liveBuildRecordsFromMetadata(service);
|
||||
const metadataRecord = metadataRecords[0] ?? null;
|
||||
if (service.serviceId === CLOUD_API_SERVICE_ID) {
|
||||
const health = await options.buildHealthPayload(options);
|
||||
return liveBuildRecordFromHealth(service, {
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "/health/live",
|
||||
payload: health
|
||||
}, metadataRecords);
|
||||
}
|
||||
|
||||
const baseUrl = String(options.env?.[service.urlEnv] || service.defaultUrl || "").replace(/\/+$/u, "");
|
||||
if (!baseUrl) {
|
||||
return metadataRecord
|
||||
? metadataRecordWithHealthGap(metadataRecord, {
|
||||
service,
|
||||
healthUrl: null,
|
||||
httpStatus: null,
|
||||
reason: `${service.serviceId} 没有配置 live health URL`
|
||||
})
|
||||
: unavailableLiveBuildRecord(service, {
|
||||
reasonCode: "service_url_unavailable",
|
||||
reason: `${service.serviceId} 没有配置 live health URL`
|
||||
});
|
||||
}
|
||||
|
||||
const healthPath = service.healthPath ?? "/health/live";
|
||||
return liveBuildRecordFromHealth(
|
||||
service,
|
||||
await fetchServiceHealth(`${baseUrl}${healthPath}`, options.fetchImpl ?? fetch),
|
||||
metadataRecords
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchServiceHealth(url, fetchImpl) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), LIVE_BUILD_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
url,
|
||||
reasonCode: "invalid_json",
|
||||
reason: `${url} returned non-JSON health payload`
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
url,
|
||||
payload,
|
||||
reasonCode: response.ok ? null : "http_error",
|
||||
reason: response.ok ? null : `${url} returned HTTP ${response.status}`
|
||||
};
|
||||
} catch (error) {
|
||||
const timedOut = error?.name === "AbortError";
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
url,
|
||||
reasonCode: timedOut ? "timeout" : "request_failed",
|
||||
reason: timedOut ? `${url} timed out after ${LIVE_BUILD_TIMEOUT_MS}ms` : error.message
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function liveBuildRecordFromHealth(service, result, metadataRecords = []) {
|
||||
const candidates = Array.isArray(metadataRecords) ? metadataRecords : [metadataRecords].filter(Boolean);
|
||||
const metadataRecord = candidates[0] ?? null;
|
||||
if (!result.ok) {
|
||||
const reason = healthUnavailableReason(service, result.reason ?? `${service.serviceId} health unavailable`);
|
||||
return metadataRecord
|
||||
? metadataRecordWithHealthGap(metadataRecord, {
|
||||
service,
|
||||
healthUrl: result.url,
|
||||
httpStatus: result.status,
|
||||
reason
|
||||
})
|
||||
: unavailableLiveBuildRecord(service, {
|
||||
healthUrl: result.url,
|
||||
httpStatus: result.status,
|
||||
reasonCode: result.reasonCode ?? "health_unavailable",
|
||||
reason
|
||||
});
|
||||
}
|
||||
|
||||
const payload = result.payload ?? {};
|
||||
const payloadServiceId = payload.serviceId ?? service.serviceId;
|
||||
if (!String(payloadServiceId).startsWith("hwlab-")) {
|
||||
return externalLiveBuildRecord(service, {
|
||||
healthUrl: result.url,
|
||||
httpStatus: result.status,
|
||||
imageReference: payload.image?.reference ?? payload.image ?? "unknown",
|
||||
imageTag: payload.image?.tag ?? null,
|
||||
imageDigest: payload.image?.digest ?? "unknown",
|
||||
commitId: payload.commit?.id ?? payload.revision ?? payload.version ?? "unknown",
|
||||
commitSource: payload.commit?.source ?? "external-health",
|
||||
revision: payload.revision ?? payload.commit?.id ?? payload.version ?? "unknown",
|
||||
reason: "外部镜像或非 HWLAB 构建产物"
|
||||
});
|
||||
}
|
||||
|
||||
const imageReference = payload.image?.reference ?? payload.image ?? "unknown";
|
||||
const commitId = payload.commit?.id ?? payload.revision ?? "unknown";
|
||||
const liveIdentity = liveBuildIdentityFromHealth({ payload, imageReference, commitId });
|
||||
const createdAt = normalizeHealthBuildTime(payload);
|
||||
if (createdAt) {
|
||||
return liveBuildHealthPayloadRecord(service, result, {
|
||||
payload,
|
||||
imageReference,
|
||||
commitId,
|
||||
createdAt,
|
||||
metadataSource: payload.build?.metadataSource ?? "health:build.createdAt",
|
||||
unavailableReason: null
|
||||
});
|
||||
}
|
||||
|
||||
const matched = selectMatchingMetadataRecord(liveIdentity, candidates);
|
||||
if (matched.record?.build?.createdAt) {
|
||||
return mergeLiveHealthOntoMetadataRecord(matched.record, {
|
||||
match: matched.match,
|
||||
liveIdentity,
|
||||
service,
|
||||
result,
|
||||
payload,
|
||||
imageReference,
|
||||
commitId
|
||||
});
|
||||
}
|
||||
|
||||
const mismatch = matched.match ?? liveBuildMetadataMatch(liveIdentity, metadataRecord);
|
||||
const unavailableReason = matched.record
|
||||
? "构建时间不可用:当前 live 镜像未提供 buildCreatedAt"
|
||||
: metadataRecord
|
||||
? `构建时间不可用:live tag 与 ${controlledMetadataLabel(metadataRecord)} 不匹配`
|
||||
: "构建时间不可用:当前 live 镜像未提供 buildCreatedAt";
|
||||
return liveBuildHealthPayloadRecord(service, result, {
|
||||
payload,
|
||||
imageReference,
|
||||
commitId,
|
||||
createdAt: null,
|
||||
metadataSource: matched.record ? "live-health:matched-controlled-metadata-without-build-time" : metadataRecord ? "live-health:controlled-metadata-mismatch" : "live-health:build-created-at-missing",
|
||||
unavailableReason,
|
||||
metadataMismatch: mismatch,
|
||||
unavailableDetail: matched.record
|
||||
? `live tag ${liveIdentity.imageTag} / revision ${liveIdentity.revision} 已匹配 ${controlledMetadataLabel(matched.record)},但该 metadata 没有 buildCreatedAt(metadata tag ${matched.record.image.tag},metadata revision ${matched.record.revision},来源 ${matched.record.build.metadataSource})`
|
||||
: metadataRecord
|
||||
? `未使用可能过期的构建时间:live tag ${liveIdentity.imageTag},live revision ${liveIdentity.revision},metadata tag ${metadataRecord.image.tag},metadata revision ${metadataRecord.revision},来源 ${metadataRecord.build.metadataSource}`
|
||||
: "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,deploy/artifact catalog 也没有可匹配的 buildCreatedAt"
|
||||
});
|
||||
}
|
||||
|
||||
function liveBuildHealthPayloadRecord(service, result, {
|
||||
payload,
|
||||
imageReference,
|
||||
commitId,
|
||||
createdAt,
|
||||
metadataSource,
|
||||
unavailableReason,
|
||||
metadataMismatch = null,
|
||||
unavailableDetail = null
|
||||
}) {
|
||||
return {
|
||||
serviceId: service.serviceId,
|
||||
name: service.serviceName,
|
||||
kind: "hwlab",
|
||||
status: createdAt ? "ok" : "build_time_unavailable",
|
||||
healthUrl: result.url,
|
||||
httpStatus: result.status,
|
||||
desiredState: desiredStateSummary(service),
|
||||
build: {
|
||||
createdAt,
|
||||
source: payload.build?.source ?? payload.commit?.source ?? "health-payload",
|
||||
metadataSource,
|
||||
unavailableReason,
|
||||
unavailableDetail,
|
||||
metadataMismatch
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? "unknown",
|
||||
digest: payload.image?.digest ?? "unknown"
|
||||
},
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: payload.commit?.source ?? "health-payload"
|
||||
},
|
||||
revision: payload.revision ?? commitId
|
||||
};
|
||||
}
|
||||
|
||||
function metadataRecordWithHealthGap(metadataRecord, { service, healthUrl, httpStatus, reason }) {
|
||||
const metadataTag = metadataRecord.image?.tag ?? "unknown";
|
||||
const metadataRevision = metadataRecord.revision ?? metadataRecord.commit?.id ?? "unknown";
|
||||
return {
|
||||
...metadataRecord,
|
||||
healthUrl,
|
||||
httpStatus,
|
||||
status: "build_time_unavailable",
|
||||
build: {
|
||||
...metadataRecord.build,
|
||||
createdAt: null,
|
||||
metadataSource: "live-health-unavailable",
|
||||
unavailableReason: `构建时间不可用:当前 live health 不可用,无法确认 ${service.serviceId} live tag/revision 是否匹配 deploy/artifact catalog metadata,未使用可能过期的构建时间(metadata tag ${metadataTag},metadata revision ${metadataRevision});${reason}`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function mergeLiveHealthOntoMetadataRecord(metadataRecord, { match, liveIdentity, service, result, payload, imageReference, commitId }) {
|
||||
return {
|
||||
...metadataRecord,
|
||||
serviceId: service.serviceId,
|
||||
name: service.serviceName,
|
||||
kind: "hwlab",
|
||||
status: "ok",
|
||||
healthUrl: result.url,
|
||||
httpStatus: result.status,
|
||||
build: {
|
||||
...metadataRecord.build,
|
||||
metadataSource: metadataRecord.build.metadataSource,
|
||||
unavailableReason: null,
|
||||
liveMetadataMatch: match,
|
||||
liveHealthMissingReason: `health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用与 live tag/revision 匹配的 ${controlledMetadataLabel(metadataRecord)}(live tag ${liveIdentity.imageTag})`
|
||||
},
|
||||
image: {
|
||||
reference: imageReference !== "unknown" ? imageReference : metadataRecord.image.reference,
|
||||
tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? metadataRecord.image.tag,
|
||||
digest: payload.image?.digest ?? metadataRecord.image.digest
|
||||
},
|
||||
commit: {
|
||||
id: commitId !== "unknown" ? commitId : metadataRecord.commit.id,
|
||||
source: payload.commit?.source ?? metadataRecord.commit.source
|
||||
},
|
||||
revision: payload.revision ?? (commitId !== "unknown" ? commitId : metadataRecord.revision)
|
||||
};
|
||||
}
|
||||
|
||||
function liveBuildIdentityFromHealth({ payload, imageReference, commitId }) {
|
||||
return {
|
||||
imageReference,
|
||||
imageTag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? "unknown",
|
||||
commitId,
|
||||
revision: payload.revision ?? commitId,
|
||||
digest: payload.image?.digest ?? "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
function selectMatchingMetadataRecord(liveIdentity, metadataRecords) {
|
||||
let firstMatched = null;
|
||||
for (const record of metadataRecords) {
|
||||
const match = liveBuildMetadataMatch(liveIdentity, record);
|
||||
if (!match.matched) continue;
|
||||
if (record.build?.createdAt) return { record, match };
|
||||
firstMatched ??= { record, match };
|
||||
}
|
||||
if (firstMatched) return firstMatched;
|
||||
return {
|
||||
record: null,
|
||||
match: metadataRecords.length ? liveBuildMetadataMatch(liveIdentity, metadataRecords[0]) : null
|
||||
};
|
||||
}
|
||||
|
||||
function liveBuildMetadataMatch(liveIdentity, metadataRecord) {
|
||||
if (!metadataRecord) {
|
||||
return {
|
||||
matched: false,
|
||||
matchedValues: [],
|
||||
live: liveBuildIdentitySummary(liveIdentity),
|
||||
metadata: null
|
||||
};
|
||||
}
|
||||
const liveValues = uniqueStrings([
|
||||
liveIdentity.imageTag,
|
||||
imageTagFromReference(liveIdentity.imageReference),
|
||||
liveIdentity.revision,
|
||||
liveIdentity.commitId,
|
||||
liveIdentity.digest
|
||||
]).filter((value) => value !== "unknown");
|
||||
const metadataValues = uniqueStrings([
|
||||
metadataRecord.image?.tag,
|
||||
imageTagFromReference(metadataRecord.image?.reference),
|
||||
metadataRecord.revision,
|
||||
metadataRecord.commit?.id,
|
||||
metadataRecord.image?.digest
|
||||
]).filter((value) => value !== "unknown" && value !== "not_published");
|
||||
const matchedValues = liveValues.filter((liveValue) =>
|
||||
metadataValues.some((metadataValue) => liveBuildIdentityEquivalent(liveValue, metadataValue))
|
||||
);
|
||||
return {
|
||||
matched: matchedValues.length > 0,
|
||||
matchedValues,
|
||||
live: liveBuildIdentitySummary(liveIdentity),
|
||||
metadata: {
|
||||
imageTag: metadataRecord.image?.tag ?? "unknown",
|
||||
revision: metadataRecord.revision ?? "unknown",
|
||||
commitId: metadataRecord.commit?.id ?? "unknown",
|
||||
digest: metadataRecord.image?.digest ?? "unknown"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function liveBuildIdentitySummary(identity) {
|
||||
return {
|
||||
imageTag: identity?.imageTag ?? "unknown",
|
||||
revision: identity?.revision ?? "unknown",
|
||||
commitId: identity?.commitId ?? "unknown",
|
||||
digest: identity?.digest ?? "unknown"
|
||||
};
|
||||
}
|
||||
|
||||
function liveBuildIdentityEquivalent(left, right) {
|
||||
const a = String(left ?? "").trim();
|
||||
const b = String(right ?? "").trim();
|
||||
if (!a || !b || a === "unknown" || b === "unknown") return false;
|
||||
if (a === b) return true;
|
||||
if (a.startsWith("sha256:") || b.startsWith("sha256:")) return false;
|
||||
return a.length >= 7 && b.length >= 7 && (a.startsWith(b) || b.startsWith(a));
|
||||
}
|
||||
|
||||
function liveBuildRecordsFromMetadata(service) {
|
||||
return [
|
||||
liveBuildRecordFromMetadataSource(service, "artifact"),
|
||||
liveBuildRecordFromMetadataSource(service, "deploy")
|
||||
].filter(Boolean);
|
||||
}
|
||||
|
||||
function liveBuildRecordFromMetadataSource(service, sourceKind) {
|
||||
const source = sourceKind === "artifact"
|
||||
? service.artifact
|
||||
: service.deploy;
|
||||
if (!source) return null;
|
||||
const createdAt = normalizeIsoTimestamp(
|
||||
sourceKind === "deploy" ? source.env?.HWLAB_BUILD_CREATED_AT : source.buildCreatedAt
|
||||
);
|
||||
|
||||
const imageReference = sourceKind === "deploy"
|
||||
? source.env?.HWLAB_IMAGE ?? source.image ?? "unknown"
|
||||
: source.image ?? service.deploy?.image ?? "unknown";
|
||||
const commitId = sourceKind === "artifact"
|
||||
? source.commitId ?? imageTagFromReference(imageReference) ?? "unknown"
|
||||
: source.env?.HWLAB_COMMIT_ID ?? imageTagFromReference(imageReference) ?? "unknown";
|
||||
const digest = sourceKind === "deploy"
|
||||
? source.env?.HWLAB_IMAGE_DIGEST ?? "unknown"
|
||||
: source.digest ?? "unknown";
|
||||
const buildSource = sourceKind === "deploy"
|
||||
? source.env?.HWLAB_BUILD_SOURCE ?? "deploy-desired-state-env"
|
||||
: source.buildSource ?? "artifact-catalog-metadata";
|
||||
const imageTag = sourceKind === "deploy"
|
||||
? source.env?.HWLAB_IMAGE_TAG ?? imageTagFromReference(imageReference) ?? "unknown"
|
||||
: source.imageTag ?? imageTagFromReference(imageReference) ?? "unknown";
|
||||
const revision = sourceKind === "deploy"
|
||||
? source.env?.HWLAB_REVISION ?? commitId
|
||||
: commitId;
|
||||
return {
|
||||
serviceId: service.serviceId,
|
||||
name: service.serviceName,
|
||||
kind: "hwlab",
|
||||
status: createdAt ? "ok" : "build_time_unavailable",
|
||||
healthUrl: null,
|
||||
httpStatus: null,
|
||||
desiredState: desiredStateSummary(service),
|
||||
build: {
|
||||
createdAt,
|
||||
source: buildSource,
|
||||
metadataSource: artifactMetadataSource(sourceKind),
|
||||
unavailableReason: createdAt
|
||||
? null
|
||||
: `构建时间不可用:${controlledMetadataLabel({ build: { metadataSource: artifactMetadataSource(sourceKind) } })} 缺少 buildCreatedAt`
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: imageTag,
|
||||
digest
|
||||
},
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: sourceKind === "artifact" ? "artifact-catalog" : "deploy-desired-state"
|
||||
},
|
||||
revision
|
||||
};
|
||||
}
|
||||
|
||||
function desiredStateSummary(service) {
|
||||
return {
|
||||
namespace: service.deploy?.namespace ?? service.artifact?.namespace ?? "hwlab-dev",
|
||||
profile: service.deploy?.profile ?? service.artifact?.profile ?? ENVIRONMENT_DEV,
|
||||
replicas: Number.isFinite(Number(service.deploy?.replicas)) ? Number(service.deploy.replicas) : null,
|
||||
healthPath: service.healthPath ?? service.deploy?.healthPath ?? service.artifact?.healthPath ?? "/health/live",
|
||||
source: service.deploy ? LIVE_BUILD_METADATA_PATHS.deployManifest : "service-defaults"
|
||||
};
|
||||
}
|
||||
|
||||
function artifactMetadataSource(sourceKind) {
|
||||
if (sourceKind === "artifact") return "artifact-catalog:buildCreatedAt";
|
||||
return "deploy-env:HWLAB_BUILD_CREATED_AT";
|
||||
}
|
||||
|
||||
function controlledMetadataLabel(record) {
|
||||
const source = String(record?.build?.metadataSource ?? "");
|
||||
if (source.includes("artifact-catalog")) return "catalog metadata";
|
||||
if (source.includes("deploy-env")) return "deploy metadata";
|
||||
return "controlled metadata";
|
||||
}
|
||||
|
||||
function unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = null, reasonCode, reason }) {
|
||||
return {
|
||||
serviceId: service.serviceId,
|
||||
name: service.serviceName,
|
||||
kind: "hwlab",
|
||||
status: "unavailable",
|
||||
healthUrl,
|
||||
httpStatus,
|
||||
desiredState: desiredStateSummary(service),
|
||||
build: {
|
||||
createdAt: null,
|
||||
metadataSource: "unavailable",
|
||||
unavailableReason: `构建时间不可用:${reason}`
|
||||
},
|
||||
image: {
|
||||
reference: "unknown",
|
||||
tag: "unknown",
|
||||
digest: "unknown"
|
||||
},
|
||||
commit: {
|
||||
id: "unknown",
|
||||
source: "unavailable"
|
||||
},
|
||||
revision: "unknown",
|
||||
error: {
|
||||
code: reasonCode,
|
||||
message: reason
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function externalLiveBuildRecord(service, {
|
||||
healthUrl = null,
|
||||
httpStatus = null,
|
||||
imageReference = "unknown",
|
||||
imageTag = null,
|
||||
imageDigest = "unknown",
|
||||
commitId = "unknown",
|
||||
commitSource = "external-image",
|
||||
revision = commitId,
|
||||
reason
|
||||
}) {
|
||||
return {
|
||||
serviceId: service.serviceId,
|
||||
name: service.serviceName,
|
||||
kind: "external",
|
||||
status: "external",
|
||||
healthUrl,
|
||||
httpStatus,
|
||||
build: {
|
||||
createdAt: null,
|
||||
metadataSource: "external-image",
|
||||
unavailableReason: reason
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: imageTag ?? imageTagFromReference(imageReference) ?? "unknown",
|
||||
digest: imageDigest
|
||||
},
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: commitSource
|
||||
},
|
||||
revision
|
||||
};
|
||||
}
|
||||
|
||||
function healthUnavailableReason(service, reason) {
|
||||
if (Number(service.deploy?.replicas) === 0) {
|
||||
return `${service.serviceId} 当前 hwlab-dev desired replicas=0,无可读取的常驻 /health/live:${reason}`;
|
||||
}
|
||||
if (service.activation === "manual") {
|
||||
return `${service.serviceId} 是手动激活服务,当前 live health 不可用:${reason}`;
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
function normalizeHealthBuildTime(payload) {
|
||||
return normalizeIsoTimestamp(
|
||||
payload?.build?.createdAt ??
|
||||
payload?.image?.createdAt ??
|
||||
payload?.buildCreatedAt ??
|
||||
payload?.metadata?.buildCreatedAt ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function loadLiveBuildMetadata(options = {}) {
|
||||
if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata);
|
||||
const root = options.repoRoot ?? process.env.HWLAB_REPO_ROOT ?? repoRoot;
|
||||
const [deployManifest, artifactCatalog] = await Promise.all([
|
||||
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.deployManifest),
|
||||
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.artifactCatalog)
|
||||
]);
|
||||
return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog });
|
||||
}
|
||||
|
||||
function normalizeLiveBuildMetadata(metadata) {
|
||||
return {
|
||||
deployManifest: normalizeMetadataFile(metadata.deployManifest),
|
||||
artifactCatalog: normalizeMetadataFile(metadata.artifactCatalog)
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonMetadata(root, relativePath) {
|
||||
try {
|
||||
const payload = JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
|
||||
return { status: "ok", path: relativePath, payload };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unavailable",
|
||||
path: relativePath,
|
||||
error: error.message,
|
||||
payload: null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMetadataFile(file) {
|
||||
if (file?.status === "ok" || file?.status === "unavailable") {
|
||||
return {
|
||||
status: file.status,
|
||||
path: file.path ?? null,
|
||||
error: file.error ?? null,
|
||||
payload: file.payload ?? null
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: file ? "ok" : "unavailable",
|
||||
path: null,
|
||||
error: null,
|
||||
payload: file ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function liveBuildServiceInventory(metadata) {
|
||||
const deployServices = Array.isArray(metadata.deployManifest.payload?.services)
|
||||
? metadata.deployManifest.payload.services
|
||||
: [];
|
||||
const deployByServiceId = new Map(deployServices.map((service) => [service.serviceId, service]));
|
||||
const artifactByServiceId = new Map(
|
||||
(Array.isArray(metadata.artifactCatalog.payload?.services) ? metadata.artifactCatalog.payload.services : [])
|
||||
.map((service) => [service.serviceId, service])
|
||||
);
|
||||
const serviceIds = uniqueStrings([
|
||||
...SERVICE_IDS,
|
||||
...deployServices.map((service) => service.serviceId),
|
||||
...artifactByServiceId.keys()
|
||||
]).filter((serviceId) => String(serviceId).startsWith("hwlab-"));
|
||||
|
||||
return [
|
||||
...serviceIds.map((serviceId) => {
|
||||
const deploy = deployByServiceId.get(serviceId) ?? null;
|
||||
const defaults = LIVE_BUILD_SERVICE_DEFAULTS[serviceId] ?? {};
|
||||
const healthPath = deploy?.healthPath ?? defaults.healthPath ?? "/health/live";
|
||||
return Object.freeze({
|
||||
serviceId,
|
||||
serviceName: deploy?.name ?? serviceId,
|
||||
urlEnv: defaults.urlEnv ?? serviceUrlEnvName(serviceId),
|
||||
defaultUrl: defaults.defaultUrl ?? serviceDefaultUrl(serviceId),
|
||||
healthPath,
|
||||
activation: deploy?.replicas === 0 ? "zero-replica" : defaults.activation,
|
||||
deploy,
|
||||
artifact: artifactByServiceId.get(serviceId) ?? null
|
||||
});
|
||||
}),
|
||||
...LIVE_BUILD_EXTERNAL_COMPONENTS
|
||||
];
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function serviceUrlEnvName(serviceId) {
|
||||
return `${serviceId.replace(/^hwlab-/u, "HWLAB_").replace(/-/gu, "_").toUpperCase()}_URL`;
|
||||
}
|
||||
|
||||
function serviceDefaultUrl(serviceId) {
|
||||
const port = {
|
||||
"hwlab-cloud-api": 6667,
|
||||
"hwlab-cloud-web": 8080,
|
||||
"hwlab-agent-mgr": 7410,
|
||||
"hwlab-agent-worker": 7411,
|
||||
"hwlab-gateway": 7001,
|
||||
"hwlab-gateway-simu": 7101,
|
||||
"hwlab-box-simu": 7201,
|
||||
"hwlab-patch-panel": 7301,
|
||||
"hwlab-router": 7401,
|
||||
"hwlab-tunnel-client": 7402,
|
||||
"hwlab-edge-proxy": 6667,
|
||||
"hwlab-cli": 7501,
|
||||
"hwlab-agent-skills": 7430
|
||||
}[serviceId] ?? 8080;
|
||||
return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`;
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, mkdtemp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export function durableBlockedRuntime({ blocker, blockedLayer, queryResult, gates }) {
|
||||
return {
|
||||
adapter: "postgres",
|
||||
durable: false,
|
||||
durableRequested: true,
|
||||
durableCapable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker,
|
||||
reason: `test durable runtime blocker ${blocker}`,
|
||||
liveRuntimeEvidence: false,
|
||||
fixtureEvidence: false,
|
||||
connection: {
|
||||
queryAttempted: true,
|
||||
queryResult,
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true,
|
||||
errorCode: "TEST_BLOCKER"
|
||||
},
|
||||
schema: {
|
||||
checked: gates.schema !== "not_checked",
|
||||
ready: gates.schema === "ready",
|
||||
missingTables: gates.schema === "blocked" ? ["gateway_sessions"] : [],
|
||||
missingColumns: gates.schema === "blocked" ? ["gateway_sessions.gateway_session_json"] : []
|
||||
},
|
||||
migration: {
|
||||
checked: gates.migration !== "not_checked",
|
||||
ready: gates.migration === "ready",
|
||||
missing: gates.migration !== "ready"
|
||||
},
|
||||
gates: Object.fromEntries(Object.entries(gates).map(([name, status]) => [
|
||||
name,
|
||||
{
|
||||
checked: status !== "not_checked",
|
||||
ready: status === "ready",
|
||||
status,
|
||||
blocker: status === "blocked" ? blocker : null
|
||||
}
|
||||
])),
|
||||
durabilityContract: {
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
requiredEvidence: "runtime_adapter_schema_migration_read_query",
|
||||
dbLiveEvidenceIsDurabilityEvidence: false,
|
||||
liveRuntimeEvidence: false,
|
||||
adapterQueryRequired: true,
|
||||
blockedLayer,
|
||||
blocker,
|
||||
secretMaterialRead: false
|
||||
},
|
||||
safety: {
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true,
|
||||
endpointRedacted: true
|
||||
},
|
||||
counts: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function durableReadyRuntime() {
|
||||
return {
|
||||
adapter: "postgres",
|
||||
durable: true,
|
||||
durableRequested: true,
|
||||
durableCapable: true,
|
||||
ready: true,
|
||||
status: "ready",
|
||||
blocker: null,
|
||||
reason: "test durable runtime ready",
|
||||
liveRuntimeEvidence: true,
|
||||
fixtureEvidence: false,
|
||||
connection: {
|
||||
queryAttempted: true,
|
||||
queryResult: "durable_readiness_ready",
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true
|
||||
},
|
||||
schema: {
|
||||
checked: true,
|
||||
ready: true,
|
||||
missingTables: [],
|
||||
missingColumns: []
|
||||
},
|
||||
migration: {
|
||||
checked: true,
|
||||
ready: true,
|
||||
missing: false
|
||||
},
|
||||
gates: Object.fromEntries(["ssl", "auth", "schema", "migration", "durability"].map((name) => [
|
||||
name,
|
||||
{
|
||||
checked: true,
|
||||
ready: true,
|
||||
status: "ready",
|
||||
blocker: null
|
||||
}
|
||||
])),
|
||||
durabilityContract: {
|
||||
ready: true,
|
||||
status: "ready",
|
||||
requiredEvidence: "runtime_adapter_schema_migration_read_query",
|
||||
dbLiveEvidenceIsDurabilityEvidence: false,
|
||||
liveRuntimeEvidence: true,
|
||||
adapterQueryRequired: true,
|
||||
blockedLayer: null,
|
||||
blocker: null,
|
||||
secretMaterialRead: false
|
||||
},
|
||||
safety: {
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true,
|
||||
endpointRedacted: true
|
||||
},
|
||||
counts: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function layerStatus(gateStatus) {
|
||||
if (gateStatus === "ready") return "pass";
|
||||
if (gateStatus === "blocked") return "blocked";
|
||||
return "not_proven";
|
||||
}
|
||||
|
||||
export function layerForBlockedCase(blockedLayer) {
|
||||
if (blockedLayer === "durability_query") return "durability";
|
||||
return blockedLayer;
|
||||
}
|
||||
|
||||
export async function m3ReadinessRequestJson(url, request = {}) {
|
||||
const parsed = new URL(url);
|
||||
const body = request.body ?? {};
|
||||
if (url.startsWith("http://gateway-1") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: m3GatewayStatus({
|
||||
gatewayId: "gwsimu_1",
|
||||
gatewaySessionId: "gws_gwsimu_1",
|
||||
boxId: "boxsimu_1",
|
||||
resourceId: "res_boxsimu_1"
|
||||
})
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-1") && parsed.pathname === "/invoke") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
operationId: "op_m3_status_do1",
|
||||
traceId: "trc_m3_status_do1",
|
||||
boxId: "boxsimu_1",
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1",
|
||||
value: false,
|
||||
state: {
|
||||
port: "DO1",
|
||||
direction: "output",
|
||||
value: false,
|
||||
source: "gateway-simu",
|
||||
updatedAt: "2026-05-23T00:00:00.000Z"
|
||||
},
|
||||
auditId: "aud_m3_status_do1",
|
||||
evidenceId: "evd_m3_status_do1"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-2") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: m3GatewayStatus({
|
||||
gatewayId: "gwsimu_2",
|
||||
gatewaySessionId: "gws_gwsimu_2",
|
||||
boxId: "boxsimu_2",
|
||||
resourceId: "res_boxsimu_2"
|
||||
})
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://gateway-2") && parsed.pathname === "/invoke") {
|
||||
const value = request.body?.operationId?.includes("do_write") || request.body?.operationId?.includes("do-write")
|
||||
? true
|
||||
: false;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
operationId: "op_m3_status_di1",
|
||||
traceId: "trc_m3_status_di1",
|
||||
boxId: "boxsimu_2",
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value,
|
||||
state: {
|
||||
port: "DI1",
|
||||
direction: "input",
|
||||
value,
|
||||
source: "patch-panel",
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
propagatedBy: "hwlab-patch-panel",
|
||||
updatedAt: "2026-05-23T00:00:00.000Z"
|
||||
},
|
||||
auditId: "aud_m3_status_di1",
|
||||
evidenceId: "evd_m3_status_di1"
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/status") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
serviceId: "hwlab-patch-panel",
|
||||
state: "active",
|
||||
activeConnections: [
|
||||
{
|
||||
fromResourceId: "res_boxsimu_1",
|
||||
fromPort: "DO1",
|
||||
toResourceId: "res_boxsimu_2",
|
||||
toPort: "DI1"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/wiring") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
wiringConfigId: "wir_m3_do1_di1",
|
||||
status: "active",
|
||||
connections: [
|
||||
{
|
||||
from: {
|
||||
resourceId: "res_boxsimu_1",
|
||||
port: "DO1"
|
||||
},
|
||||
to: {
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1"
|
||||
},
|
||||
mode: "exclusive"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (url.startsWith("http://patch-panel") && parsed.pathname === "/sync/tick") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
accepted: true,
|
||||
propagatedBy: "hwlab-patch-panel",
|
||||
routeCount: 1,
|
||||
deliveryCount: 1,
|
||||
routes: [
|
||||
{
|
||||
accepted: true,
|
||||
deliveryCount: 1,
|
||||
deliveries: [
|
||||
{
|
||||
resourceId: "res_boxsimu_2",
|
||||
port: "DI1",
|
||||
value: body.signals?.[0]?.value ?? false,
|
||||
sourceResourceId: "res_boxsimu_1",
|
||||
sourcePort: "DO1",
|
||||
deliveryStatus: "applied"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected M3 readiness request ${url}`);
|
||||
}
|
||||
|
||||
export function m3GatewayStatus({ gatewayId, gatewaySessionId, boxId, resourceId }) {
|
||||
return {
|
||||
serviceId: "hwlab-gateway-simu",
|
||||
gatewayId,
|
||||
gatewaySessionId,
|
||||
session: {
|
||||
gatewayId,
|
||||
gatewaySessionId
|
||||
},
|
||||
registry: {
|
||||
gatewayId,
|
||||
gatewaySessionId,
|
||||
boxes: [
|
||||
{
|
||||
boxId,
|
||||
resourceId,
|
||||
state: "registered"
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function codexStdioReadyFixture({ workspace, codexHome }) {
|
||||
return {
|
||||
kind: "codex-app-server-stdio-runner",
|
||||
provider: "codex-stdio",
|
||||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||||
status: "feasible",
|
||||
ready: true,
|
||||
startupReady: true,
|
||||
canStartLongLivedCodexStdio: true,
|
||||
command: path.join(process.cwd(), "node_modules", ".bin", "codex"),
|
||||
binaryOnPath: true,
|
||||
binary: {
|
||||
present: true,
|
||||
executable: true,
|
||||
nativeDependencyPresent: true,
|
||||
versionDetected: true
|
||||
},
|
||||
workspace,
|
||||
workspaceState: { exists: true, readable: true, writable: true, writeRequired: true },
|
||||
codexHome,
|
||||
codexHomeState: { exists: true, readable: true, writable: true, writeRequired: true },
|
||||
sandbox: "workspace-write",
|
||||
enabled: true,
|
||||
supervisor: { configured: true, mode: "repo-owned-node-supervisor" },
|
||||
tokenBoundary: { present: true, secretMaterialRead: false, valuesRedacted: true },
|
||||
egress: { configured: true, directPublicOpenAi: false, valueRedacted: true },
|
||||
protocol: {
|
||||
status: "wired",
|
||||
wired: true,
|
||||
requiredTools: ["codex", "codex-reply"],
|
||||
toolsObserved: ["codex", "codex-reply"],
|
||||
missingTools: [],
|
||||
command: "codex app-server --listen stdio://"
|
||||
},
|
||||
commandProbe: {
|
||||
status: "ready",
|
||||
ready: true,
|
||||
probe: "workspace.pwd",
|
||||
traceId: "trc_codex_stdio_command_probe",
|
||||
toolCalls: [{
|
||||
name: "pwd",
|
||||
status: "completed",
|
||||
cwd: workspace,
|
||||
command: "pwd",
|
||||
exitCode: 0,
|
||||
stdoutSummary: "workspace path matched",
|
||||
outputTruncated: false
|
||||
}],
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true
|
||||
},
|
||||
stdioProtocol: {
|
||||
status: "wired",
|
||||
wired: true,
|
||||
requiredTools: ["codex", "codex-reply"],
|
||||
toolsObserved: ["codex", "codex-reply"],
|
||||
missingTools: [],
|
||||
command: "codex app-server --listen stdio://"
|
||||
},
|
||||
sessionLifecycle: {
|
||||
status: "present",
|
||||
present: true,
|
||||
create: true,
|
||||
reuse: true,
|
||||
cancel: true,
|
||||
reap: true,
|
||||
traceCapture: true,
|
||||
idleTimeoutMs: 1800000
|
||||
},
|
||||
lifecycleSupervisor: {
|
||||
status: "present",
|
||||
present: true,
|
||||
create: true,
|
||||
reuse: true,
|
||||
cancel: true,
|
||||
reap: true,
|
||||
traceCapture: true,
|
||||
idleTimeoutMs: 1800000
|
||||
},
|
||||
runtimeContract: {
|
||||
status: "ready",
|
||||
ready: true,
|
||||
binary: {
|
||||
status: "present",
|
||||
present: true,
|
||||
executable: true,
|
||||
nativeDependencyPresent: true,
|
||||
versionDetected: true
|
||||
},
|
||||
stdioProtocol: {
|
||||
status: "wired",
|
||||
wired: true,
|
||||
requiredTools: ["codex", "codex-reply"],
|
||||
toolsObserved: ["codex", "codex-reply"],
|
||||
missingTools: []
|
||||
},
|
||||
commandProbe: {
|
||||
status: "ready",
|
||||
ready: true,
|
||||
probe: "workspace.pwd",
|
||||
traceId: "trc_codex_stdio_command_probe",
|
||||
toolCalls: [{
|
||||
name: "pwd",
|
||||
status: "completed",
|
||||
cwd: workspace,
|
||||
command: "pwd",
|
||||
exitCode: 0,
|
||||
stdoutSummary: "workspace path matched",
|
||||
outputTruncated: false
|
||||
}],
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true
|
||||
},
|
||||
lifecycleSupervisor: {
|
||||
status: "present",
|
||||
present: true,
|
||||
create: true,
|
||||
reuse: true,
|
||||
cancel: true,
|
||||
reap: true,
|
||||
traceCapture: true,
|
||||
idleTimeoutMs: 1800000
|
||||
},
|
||||
workspaceMount: {
|
||||
path: workspace,
|
||||
status: "ready",
|
||||
mounted: true,
|
||||
readable: true,
|
||||
writable: true,
|
||||
sandbox: "workspace-write"
|
||||
},
|
||||
codexHome: {
|
||||
path: codexHome,
|
||||
status: "ready",
|
||||
exists: true,
|
||||
readable: true,
|
||||
writable: true
|
||||
},
|
||||
cancelReapTraceReadiness: {
|
||||
status: "ready",
|
||||
cancel: true,
|
||||
reap: true,
|
||||
traceCapture: true,
|
||||
idleTimeout: true
|
||||
}
|
||||
},
|
||||
blockers: [],
|
||||
blockerCodes: [],
|
||||
safety: { secretMaterialRead: false, valuesRedacted: true }
|
||||
};
|
||||
}
|
||||
|
||||
export function codexStdioChatFixture({ workspace, codexHome, params }) {
|
||||
const traceId = params.traceId;
|
||||
const session = {
|
||||
sessionId: "ses_server_stdio_pwd",
|
||||
conversationId: params.conversationId,
|
||||
status: "idle",
|
||||
workspace,
|
||||
sandbox: "workspace-write",
|
||||
runnerKind: "codex-app-server-stdio-runner",
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
capabilityLevel: "long-lived-codex-stdio-session",
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
createdAt: "2026-05-23T00:00:00.000Z",
|
||||
updatedAt: "2026-05-23T00:00:00.000Z",
|
||||
idleTimeoutMs: 1800000,
|
||||
expiresAt: "2026-05-23T00:30:00.000Z",
|
||||
lastTraceId: traceId,
|
||||
turn: 1,
|
||||
threadId: "thread_server_stdio_pwd",
|
||||
reused: false,
|
||||
durable: true,
|
||||
longLivedSession: true,
|
||||
codexStdio: true,
|
||||
writeCapable: true,
|
||||
secretMaterialStored: false,
|
||||
valuesRedacted: true
|
||||
};
|
||||
const feasibility = codexStdioReadyFixture({ workspace, codexHome });
|
||||
return {
|
||||
provider: "codex-stdio",
|
||||
model: "gpt-test",
|
||||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||||
content: `stdio pwd\n${workspace}`,
|
||||
workspace,
|
||||
sandbox: "workspace-write",
|
||||
session,
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
sessionReuse: {
|
||||
conversationId: params.conversationId,
|
||||
sessionId: session.sessionId,
|
||||
threadId: session.threadId,
|
||||
mapped: true,
|
||||
reused: false,
|
||||
turn: 1,
|
||||
previousTurns: 0,
|
||||
workspace,
|
||||
status: "idle",
|
||||
idleTimeoutMs: 1800000
|
||||
},
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
runnerLimitations: ["secret-values-redacted"],
|
||||
codexStdioFeasibility: feasibility,
|
||||
longLivedSessionGate: {
|
||||
status: "pass",
|
||||
pass: true,
|
||||
provider: "codex-stdio",
|
||||
runnerKind: "codex-app-server-stdio-runner",
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
blockers: []
|
||||
},
|
||||
toolCalls: [{
|
||||
id: "tool_server_stdio_pwd",
|
||||
type: "workspace-read",
|
||||
name: "pwd",
|
||||
status: "completed",
|
||||
cwd: workspace,
|
||||
command: "pwd",
|
||||
exitCode: 0,
|
||||
stdout: workspace,
|
||||
stderrSummary: "",
|
||||
outputTruncated: false,
|
||||
traceId
|
||||
}],
|
||||
skills: {
|
||||
status: "not_requested",
|
||||
items: [],
|
||||
count: 0,
|
||||
blockers: []
|
||||
},
|
||||
runner: {
|
||||
kind: "codex-app-server-stdio-runner",
|
||||
provider: "codex-stdio",
|
||||
backend: "hwlab-cloud-api/codex-app-server-stdio",
|
||||
workspace,
|
||||
sandbox: "workspace-write",
|
||||
session: "codex-app-server-stdio-long-lived",
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
sessionId: session.sessionId,
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
codexStdio: true,
|
||||
longLivedSession: true,
|
||||
durableSession: true,
|
||||
writeCapable: true,
|
||||
readOnly: false,
|
||||
capabilityLevel: "long-lived-codex-stdio-session"
|
||||
},
|
||||
runnerTrace: {
|
||||
traceId,
|
||||
runnerKind: "codex-app-server-stdio-runner",
|
||||
workspace,
|
||||
sandbox: "workspace-write",
|
||||
sessionMode: "codex-app-server-stdio-long-lived",
|
||||
sessionId: session.sessionId,
|
||||
sessionStatus: "idle",
|
||||
turn: 1,
|
||||
implementationType: "repo-owned-codex-app-server-stdio-session",
|
||||
events: ["stdio:acquire", "session:created", "stdio:ready", "tool:pwd:completed"],
|
||||
outputTruncated: false,
|
||||
valuesPrinted: false
|
||||
},
|
||||
capabilityLevel: "long-lived-codex-stdio-session",
|
||||
providerTrace: {
|
||||
transport: "stdio",
|
||||
protocol: "codex-app-server-jsonrpc-stdio",
|
||||
command: "codex app-server --listen stdio://",
|
||||
toolName: "codex",
|
||||
threadId: "thread_server_stdio_pwd",
|
||||
valuesPrinted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export async function postAgent(port, body) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(body.traceId ? { "x-trace-id": body.traceId } : {})
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function pollAgentResult(port, traceId) {
|
||||
let last = null;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat/result/${encodeURIComponent(traceId)}`);
|
||||
last = {
|
||||
status: response.status,
|
||||
body: await response.json()
|
||||
};
|
||||
if (response.status === 200) return last.body;
|
||||
await delay(10);
|
||||
}
|
||||
throw new Error(`Code Agent result did not complete: ${JSON.stringify(last)}`);
|
||||
}
|
||||
|
||||
export async function createFakeCodexCommand() {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-fake-codex-"));
|
||||
const packageRoot = path.join(root, "node_modules", "@openai", "codex");
|
||||
const command = path.join(packageRoot, "bin", "codex.js");
|
||||
const native = path.join(root, "node_modules", "@openai", "codex-linux-x64", "vendor", "x86_64-unknown-linux-musl", "codex", "codex");
|
||||
await mkdir(path.dirname(command), { recursive: true });
|
||||
await mkdir(path.dirname(native), { recursive: true });
|
||||
await writeFile(path.join(packageRoot, "package.json"), JSON.stringify({ name: "@openai/codex", version: "0.128.0" }), "utf8");
|
||||
await writeFile(command, "#!/usr/bin/env node\nconsole.log('codex 0.128.0');\n", "utf8");
|
||||
await writeFile(native, "#!/bin/sh\nexit 0\n", "utf8");
|
||||
await chmod(command, 0o755);
|
||||
await chmod(native, 0o755);
|
||||
return { root, command };
|
||||
}
|
||||
|
||||
export async function prepareFakeCodexHome(codexHome) {
|
||||
await mkdir(codexHome, { recursive: true });
|
||||
await writeFile(path.join(codexHome, "config.toml"), "model = \"gpt-test\"\n", "utf8");
|
||||
await writeFile(path.join(codexHome, "auth.json"), "{\"auth\":\"redacted-test-placeholder\"}\n", "utf8");
|
||||
return codexHome;
|
||||
}
|
||||
|
||||
export function createFakeAppServerClient({ text = "真实 Codex stdio 回复", delayMs = 0 } = {}) {
|
||||
let notificationHandler = null;
|
||||
return {
|
||||
async initialize() {
|
||||
return { initialized: true };
|
||||
},
|
||||
setNotificationHandler(handler) {
|
||||
notificationHandler = typeof handler === "function" ? handler : null;
|
||||
},
|
||||
async startThread() {
|
||||
notificationHandler?.({ method: "thread/started", params: { thread: { id: "thread_server_test_stdio" } } });
|
||||
return { threadId: "thread_server_test_stdio" };
|
||||
},
|
||||
async resumeThread(args) {
|
||||
notificationHandler?.({ method: "thread/started", params: { thread: { id: args.threadId } } });
|
||||
return { threadId: args.threadId };
|
||||
},
|
||||
async startTurn() {
|
||||
const turnId = "turn_server_test_stdio";
|
||||
notificationHandler?.({ method: "turn/started", params: { turn: { id: turnId } } });
|
||||
if (delayMs > 0) await delay(delayMs);
|
||||
notificationHandler?.({ method: "item/agentMessage/delta", params: { itemId: "item_server_test_stdio", delta: text } });
|
||||
notificationHandler?.({ method: "item/completed", params: { item: { id: "item_server_test_stdio", type: "agentMessage", text } } });
|
||||
notificationHandler?.({ method: "turn/completed", params: { turn: { id: turnId, status: "completed" } } });
|
||||
return { turnId };
|
||||
},
|
||||
close() {}
|
||||
};
|
||||
}
|
||||
|
||||
export async function postAgentRaw(port, body, headers = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/agent/chat`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...headers
|
||||
},
|
||||
body
|
||||
});
|
||||
return {
|
||||
status: response.status,
|
||||
body: await response.json()
|
||||
};
|
||||
}
|
||||
|
||||
export function createGreenM3StatusRuntimeStore({ blocker = null } = {}) {
|
||||
return {
|
||||
async readiness() {
|
||||
return {
|
||||
adapter: "postgres",
|
||||
durable: true,
|
||||
durableRequested: true,
|
||||
durableCapable: true,
|
||||
ready: true,
|
||||
status: "ready",
|
||||
blocker,
|
||||
reason: "Postgres durable runtime adapter completed live schema, migration, and read readiness queries",
|
||||
liveRuntimeEvidence: true,
|
||||
fixtureEvidence: false
|
||||
};
|
||||
},
|
||||
async queryAuditEvents() {
|
||||
return {
|
||||
events: [
|
||||
{
|
||||
auditId: "aud_m3_di_read_d5afd84b-3a9d-475f-894f-dff8ea5f49ff_succeeded",
|
||||
traceId: "trc_hotfix_307_verify_1779527747043_read_false",
|
||||
actorType: "user",
|
||||
actorId: "usr_m3_operator",
|
||||
action: "m3.io.di.read",
|
||||
targetType: "hardware_operation",
|
||||
targetId: "op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f",
|
||||
projectId: "prj_mvp_topology",
|
||||
gatewaySessionId: "gws_gwsimu_2",
|
||||
operationId: "op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f",
|
||||
serviceId: "hwlab-cloud-api",
|
||||
outcome: "succeeded",
|
||||
metadata: {
|
||||
route: "res_boxsimu_1:DO1->hwlab-patch-panel->res_boxsimu_2:DI1"
|
||||
},
|
||||
environment: "dev",
|
||||
occurredAt: "2026-05-23T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
count: 1
|
||||
};
|
||||
},
|
||||
async queryEvidenceRecords() {
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
evidenceId: "evd_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f_succeeded",
|
||||
projectId: "prj_mvp_topology",
|
||||
operationId: "op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f",
|
||||
kind: "trace",
|
||||
uri: "memory://hwlab-cloud-api/op_m3_di_read_75fc664f-e94f-41e8-816e-aff9fdb3666f/m3-io-control.json",
|
||||
sha256: "0".repeat(64),
|
||||
serviceId: "hwlab-cloud-api",
|
||||
environment: "dev",
|
||||
metadata: {
|
||||
traceId: "trc_hotfix_307_verify_1779527747043_read_false",
|
||||
route: "res_boxsimu_1:DO1->hwlab-patch-panel->res_boxsimu_2:DI1"
|
||||
},
|
||||
createdAt: "2026-05-23T00:00:00.000Z"
|
||||
}
|
||||
],
|
||||
count: 1
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,726 @@
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
buildMetadataFromEnv,
|
||||
imageTagFromReference,
|
||||
normalizeIsoTimestamp
|
||||
} from "../build-metadata.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES, SERVICE_IDS } from "../protocol/index.mjs";
|
||||
import {
|
||||
AUDIT_FIELD_NAMES,
|
||||
CLOUD_API_SERVICE_ID,
|
||||
createAuditRecord
|
||||
} from "../audit/index.mjs";
|
||||
import {
|
||||
SUPPORTED_RPC_METHODS,
|
||||
createErrorEnvelope,
|
||||
handleJsonRpcRequest
|
||||
} from "./json-rpc.ts";
|
||||
import {
|
||||
createCodeAgentErrorPayload,
|
||||
describeCodeAgentAvailability,
|
||||
handleCodeAgentChat
|
||||
} from "./code-agent-chat.ts";
|
||||
import { defaultCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
||||
import { createCodeAgentSessionRegistry } from "./code-agent-session-registry.ts";
|
||||
import { codeAgentSessionLifecycleSummary } from "./code-agent-session-lifecycle.ts";
|
||||
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
|
||||
import { buildGateDiagnosticsRows } from "./gate-diagnostics.ts";
|
||||
import {
|
||||
applyRuntimeDbReadinessLayers,
|
||||
buildDbRuntimeReadiness
|
||||
} from "./db-contract.ts";
|
||||
import { buildCloudApiReadiness } from "./health-contract.ts";
|
||||
import {
|
||||
M3_IO_CONTROL_ROUTE,
|
||||
M3_STATUS_ROUTE,
|
||||
describeM3IoControl,
|
||||
describeM3IoControlLive,
|
||||
describeM3StatusLive,
|
||||
handleM3IoControl
|
||||
} from "./m3-io-control.ts";
|
||||
import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.ts";
|
||||
import {
|
||||
buildDevicePodRestPayload,
|
||||
buildDevicePodStatus
|
||||
} from "../device-pod/fake-data.mjs";
|
||||
import { createGatewayDemoRegistry } from "./gateway-demo-registry.ts";
|
||||
import { buildDevicePodCloudApiPayload, buildLiveBuildsPayload } from "./server-rest-payloads.ts";
|
||||
import {
|
||||
createCodeAgentChatResultStore,
|
||||
handleCodeAgentCancelHttp,
|
||||
handleCodeAgentChatHttp,
|
||||
handleCodeAgentChatResultHttp,
|
||||
handleCodeAgentInspectHttp,
|
||||
handleCodeAgentTraceHttp
|
||||
} from "./server-code-agent-http.ts";
|
||||
import { handleM3IoControlHttp } from "./server-m3-http.ts";
|
||||
|
||||
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||
const LIVE_BUILD_TIMEOUT_MS = 900;
|
||||
const DEFAULT_CODE_AGENT_RESULT_TRACE_EVENT_LIMIT = 120;
|
||||
const DEFAULT_CODE_AGENT_PROVIDER_PROFILE = "deepseek";
|
||||
const CODE_AGENT_PROVIDER_PROFILE_IDS = Object.freeze(["deepseek", "codex-api", "runtime-default"]);
|
||||
const CODE_AGENT_PROVIDER_PROFILE_LABELS = Object.freeze({
|
||||
"deepseek": "DeepSeek",
|
||||
"codex-api": "Codex API",
|
||||
"runtime-default": "运行默认"
|
||||
});
|
||||
const DEFAULT_CODE_AGENT_CODEX_API_MODEL = "gpt-5.5";
|
||||
const DEFAULT_CODE_AGENT_CODEX_API_BASE_URL = "http://127.0.0.1:49280/responses";
|
||||
const DEFAULT_CODE_AGENT_DEEPSEEK_MODEL = "deepseek-chat";
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const LIVE_BUILD_METADATA_PATHS = Object.freeze({
|
||||
deployManifest: "deploy/deploy.json",
|
||||
artifactCatalog: "deploy/artifact-catalog.dev.json"
|
||||
});
|
||||
const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
|
||||
"hwlab-cloud-api": Object.freeze({ urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
|
||||
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
|
||||
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
|
||||
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411" }),
|
||||
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL", defaultUrl: "http://hwlab-device-pod.hwlab-dev.svc.cluster.local:7601" }),
|
||||
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001" }),
|
||||
"hwlab-gateway-simu": Object.freeze({ urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
|
||||
"hwlab-box-simu": Object.freeze({ urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
|
||||
"hwlab-patch-panel": Object.freeze({ urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }),
|
||||
"hwlab-router": Object.freeze({ urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }),
|
||||
"hwlab-tunnel-client": Object.freeze({ urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }),
|
||||
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
|
||||
"hwlab-cli": Object.freeze({ urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501" }),
|
||||
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
|
||||
});
|
||||
const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
|
||||
Object.freeze({
|
||||
serviceId: "hwlab-frpc",
|
||||
serviceName: "hwlab-frpc",
|
||||
kind: "external",
|
||||
externalImage: true,
|
||||
defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1",
|
||||
externalReason: "外部镜像或非 HWLAB 构建产物"
|
||||
})
|
||||
]);
|
||||
|
||||
function runtimeEnvironment(env = process.env) {
|
||||
const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV;
|
||||
return typeof value === "string" && value.trim() ? value.trim() : ENVIRONMENT_DEV;
|
||||
}
|
||||
|
||||
export function createCloudApiServer(options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
ensureCodeAgentRuntimeBase(env);
|
||||
const sessionRegistry = options.sessionRegistry || createCodeAgentSessionRegistry();
|
||||
const traceStore = options.traceStore || defaultCodeAgentTraceStore;
|
||||
const codexStdioManager = options.codexStdioManager || createCodexStdioSessionManager({ traceStore });
|
||||
const codeAgentChatResults = options.codeAgentChatResults || createCodeAgentChatResultStore({
|
||||
maxResults: parsePositiveInteger(env.HWLAB_CODE_AGENT_CHAT_RESULT_CACHE_SIZE, 256)
|
||||
});
|
||||
const runtimeStore = options.runtimeStore || createConfiguredCloudRuntimeStore({ ...options, env });
|
||||
const gatewayRegistry = options.gatewayRegistry || createGatewayDemoRegistry({
|
||||
staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000),
|
||||
dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 120000)
|
||||
});
|
||||
return createServer(async (request, response) => {
|
||||
try {
|
||||
await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults });
|
||||
} catch (error) {
|
||||
sendJson(response, 500, {
|
||||
error: {
|
||||
code: "internal_error",
|
||||
message: "hwlab-cloud-api request handling failed",
|
||||
reason: error.message
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureCodeAgentRuntimeBase(env = process.env) {
|
||||
const workspace = env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab";
|
||||
const codexHome = env.CODEX_HOME || env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home";
|
||||
const appCodexCommand = "/app/node_modules/.bin/codex";
|
||||
const localCodexCommand = path.join(process.cwd(), "node_modules", ".bin", "codex");
|
||||
const codexCommand = existsSync(appCodexCommand)
|
||||
? appCodexCommand
|
||||
: existsSync(localCodexCommand)
|
||||
? localCodexCommand
|
||||
: appCodexCommand;
|
||||
env.HWLAB_CODE_AGENT_WORKSPACE ||= workspace;
|
||||
env.HWLAB_CODE_AGENT_CODEX_WORKSPACE ||= workspace;
|
||||
env.HWLAB_CODE_AGENT_CODEX_SANDBOX ||= "danger-full-access";
|
||||
env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED ||= "1";
|
||||
env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR ||= "repo-owned";
|
||||
env.CODEX_HOME ||= codexHome;
|
||||
env.HWLAB_CODE_AGENT_CODEX_COMMAND ||= codexCommand;
|
||||
env.HWLAB_CODE_AGENT_SKILLS_DIRS ||= "/app/skills";
|
||||
|
||||
for (const target of [path.dirname(workspace), codexHome]) {
|
||||
try {
|
||||
mkdirSync(target, { recursive: true });
|
||||
} catch {
|
||||
// Runtime health reports the concrete missing/writable blocker.
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (!existsSync(workspace)) {
|
||||
if (workspace === "/workspace/hwlab" && existsSync("/app")) {
|
||||
symlinkSync("/app", workspace, "dir");
|
||||
} else {
|
||||
mkdirSync(workspace, { recursive: true });
|
||||
}
|
||||
}
|
||||
lstatSync(workspace);
|
||||
} catch {
|
||||
// Runtime health reports the concrete missing/writable blocker.
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildHealthPayload(options = {}) {
|
||||
const serviceId = CLOUD_API_SERVICE_ID;
|
||||
const env = options.env ?? process.env;
|
||||
ensureCodeAgentRuntimeBase(env);
|
||||
const metadata = buildMetadataFromEnv(env, {
|
||||
serviceId,
|
||||
fallbackImageRepository: "ghcr.io/pikastech/hwlab-cloud-api"
|
||||
});
|
||||
const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe);
|
||||
const codeAgent = await describeCodeAgentAvailability(env, options);
|
||||
const runtime = await runtimeReadiness(options.runtimeStore ?? createConfiguredCloudRuntimeStore({ ...options, env }));
|
||||
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
|
||||
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
|
||||
|
||||
return {
|
||||
serviceId,
|
||||
environment: runtimeEnvironment(env),
|
||||
status: readiness.status,
|
||||
ready: readiness.ready,
|
||||
revision: metadata.revision,
|
||||
readiness,
|
||||
blockers: readiness.blockers,
|
||||
blockerCodes: readiness.blockerCodes,
|
||||
service: {
|
||||
id: serviceId,
|
||||
role: "cloud-api",
|
||||
healthPath: "/health",
|
||||
livePath: "/health/live"
|
||||
},
|
||||
commit: metadata.commit,
|
||||
image: metadata.image,
|
||||
build: metadata.build,
|
||||
endpoint: env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
|
||||
observedAt: new Date().toISOString(),
|
||||
db,
|
||||
codeAgent,
|
||||
runtime
|
||||
};
|
||||
}
|
||||
|
||||
async function routeRequest(request, response, options) {
|
||||
const url = new URL(request.url || "/", "http://hwlab-cloud-api.local");
|
||||
|
||||
if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/health/live")) {
|
||||
sendJson(response, 200, await buildHealthPayload(options));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/live") {
|
||||
sendJson(response, 200, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
status: "live"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && (url.pathname === "/rpc" || url.pathname === "/json-rpc")) {
|
||||
await handleRpcHttpRequest(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1" || url.pathname.startsWith("/v1/")) {
|
||||
await handleRestAdapter(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
|
||||
sendRestError(request, response, 404, "not_found", "Route is not part of the L1 cloud-api runtime", {
|
||||
operation: `${request.method || "GET"} ${url.pathname}`,
|
||||
target: {
|
||||
type: "route",
|
||||
id: url.pathname
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRpcHttpRequest(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let envelope;
|
||||
|
||||
try {
|
||||
envelope = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(
|
||||
response,
|
||||
400,
|
||||
createErrorEnvelope({
|
||||
id: "req_unassigned",
|
||||
code: ERROR_CODES.parseError,
|
||||
message: "Invalid JSON body",
|
||||
data: {
|
||||
reason: error.message
|
||||
},
|
||||
context: {
|
||||
operation: "json_rpc.parse",
|
||||
target: {
|
||||
type: "http_route",
|
||||
id: request.url || "/rpc"
|
||||
},
|
||||
result: "rejected"
|
||||
}
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rpcResponse = await handleJsonRpcRequest(envelope, {
|
||||
adapter: "json-rpc",
|
||||
runtimeStore: options.runtimeStore,
|
||||
env: options.env,
|
||||
dbProbe: options.dbProbe,
|
||||
m3IoRequestJson: options.m3IoRequestJson,
|
||||
gatewayRegistry: options.gatewayRegistry
|
||||
});
|
||||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||||
}
|
||||
|
||||
async function handleRestAdapter(request, response, url, options) {
|
||||
if (request.method === "GET" && url.pathname === "/v1") {
|
||||
const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe);
|
||||
const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
||||
const runtime = await runtimeReadiness(options.runtimeStore);
|
||||
const db = applyRuntimeDbReadinessLayers(dbProbe, runtime);
|
||||
const readiness = buildCloudApiReadiness({ db, codeAgent, runtime });
|
||||
sendJson(response, 200, {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
adapter: "rest",
|
||||
status: readiness.status,
|
||||
ready: readiness.ready,
|
||||
rpcBridge: "POST /v1/rpc/{method}",
|
||||
codeAgent,
|
||||
methods: SUPPORTED_RPC_METHODS,
|
||||
auditFields: AUDIT_FIELD_NAMES,
|
||||
db,
|
||||
runtime,
|
||||
readiness,
|
||||
blockers: readiness.blockers,
|
||||
blockerCodes: readiness.blockerCodes,
|
||||
devicePod: {
|
||||
route: "/v1/device-pods",
|
||||
statusRoute: "/v1/device-pods/{devicePodId}/status",
|
||||
eventsRoute: "/v1/device-pods/{devicePodId}/events",
|
||||
contractVersion: "device-pod-fake-v1",
|
||||
frontendCallsOnly: "/v1/device-pods",
|
||||
legacyHardwareUi: "disabled"
|
||||
},
|
||||
m3IoControl: describeM3IoControl(options)
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && (url.pathname === "/v1/device-pods" || url.pathname.startsWith("/v1/device-pods/"))) {
|
||||
sendJson(response, 200, await buildDevicePodCloudApiPayload(url, options));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||||
sendJson(response, 200, await describeM3IoControlLive(options));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === M3_STATUS_ROUTE) {
|
||||
sendJson(response, 200, await describeM3StatusLive(options));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/v1/diagnostics/gate") {
|
||||
sendJson(response, 200, await buildGateDiagnosticsRows(options));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
|
||||
sendJson(response, 200, await buildLiveBuildsPayload(options, { buildHealthPayload, runtimeEnvironment }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/v1/gateway/sessions") {
|
||||
sendJson(response, 200, options.gatewayRegistry.describe());
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/v1/gateway/poll") {
|
||||
await handleGatewayPollHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/v1/gateway/result") {
|
||||
await handleGatewayResultHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === M3_IO_CONTROL_ROUTE) {
|
||||
await handleM3IoControlHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/v1/agent/chat") {
|
||||
await handleCodeAgentChatHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/v1/agent/chat/inspect") {
|
||||
await handleCodeAgentInspectHttp(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/result/")) {
|
||||
await handleCodeAgentChatResultHttp(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") {
|
||||
await handleCodeAgentCancelHttp(request, response, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) {
|
||||
await handleCodeAgentTraceHttp(request, response, url, options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method !== "POST" || !url.pathname.startsWith("/v1/rpc/")) {
|
||||
sendRestError(request, response, 404, "not_found", "REST route is not implemented in the L1 cloud-api runtime", {
|
||||
operation: `${request.method || "GET"} ${url.pathname}`,
|
||||
target: {
|
||||
type: "route",
|
||||
id: url.pathname
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const method = decodeURIComponent(url.pathname.slice("/v1/rpc/".length));
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendRestError(request, response, 400, "parse_error", "Invalid JSON body", {
|
||||
operation: method,
|
||||
target: {
|
||||
type: "rpc_method",
|
||||
id: method
|
||||
},
|
||||
result: "rejected",
|
||||
reason: error.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
sendRestError(request, response, 400, "invalid_params", "REST RPC bridge body must be a JSON object", {
|
||||
operation: method,
|
||||
target: {
|
||||
type: "rpc_method",
|
||||
id: method
|
||||
},
|
||||
result: "rejected"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const rpcResponse = await handleJsonRpcRequest(
|
||||
{
|
||||
jsonrpc: "2.0",
|
||||
id: getHeader(request, "x-request-id") || `req_${randomUUID()}`,
|
||||
method,
|
||||
params,
|
||||
meta: {
|
||||
traceId: getHeader(request, "x-trace-id") || `trc_${randomUUID()}`,
|
||||
actorId: getHeader(request, "x-actor-id"),
|
||||
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
||||
environment: runtimeEnvironment(options.env ?? process.env)
|
||||
}
|
||||
},
|
||||
{
|
||||
adapter: "rest",
|
||||
runtimeStore: options.runtimeStore,
|
||||
env: options.env,
|
||||
dbProbe: options.dbProbe,
|
||||
m3IoRequestJson: options.m3IoRequestJson,
|
||||
gatewayRegistry: options.gatewayRegistry
|
||||
}
|
||||
);
|
||||
|
||||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||||
}
|
||||
|
||||
async function handleGatewayPollHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(response, 400, {
|
||||
accepted: false,
|
||||
error: {
|
||||
code: "parse_error",
|
||||
message: "Invalid JSON body",
|
||||
reason: error.message
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!params || typeof params !== "object" || Array.isArray(params)) {
|
||||
sendJson(response, 400, {
|
||||
accepted: false,
|
||||
error: {
|
||||
code: "invalid_params",
|
||||
message: "gateway poll body must be a JSON object"
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = options.gatewayRegistry.updateSession(params);
|
||||
await persistGatewayDemoSession({
|
||||
runtimeStore: options.runtimeStore,
|
||||
session,
|
||||
request,
|
||||
env: options.env
|
||||
});
|
||||
const outbound = options.gatewayRegistry.nextRequest(session.gatewaySessionId);
|
||||
sendJson(response, 200, {
|
||||
accepted: true,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
mode: "gateway-outbound-poll-demo",
|
||||
gatewayId: session.gatewayId,
|
||||
gatewaySessionId: session.gatewaySessionId,
|
||||
queueDepth: session.queue.length,
|
||||
request: outbound,
|
||||
type: outbound ? "request" : "noop",
|
||||
observedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
async function handleGatewayResultHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
try {
|
||||
params = body ? JSON.parse(body) : {};
|
||||
} catch (error) {
|
||||
sendJson(response, 400, {
|
||||
accepted: false,
|
||||
error: {
|
||||
code: "parse_error",
|
||||
message: "Invalid JSON body",
|
||||
reason: error.message
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const result = options.gatewayRegistry.complete(params);
|
||||
sendJson(response, result.accepted ? 200 : 404, {
|
||||
...result,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
mode: "gateway-outbound-poll-demo",
|
||||
observedAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
async function persistGatewayDemoSession({ runtimeStore, session, request, env = process.env }) {
|
||||
if (!runtimeStore) return;
|
||||
const refreshMs = parsePositiveInteger(env.HWLAB_GATEWAY_REGISTRATION_REFRESH_MS, 60000);
|
||||
if (session.runtimeRegisteredEpochMs && Date.now() - session.runtimeRegisteredEpochMs < refreshMs) {
|
||||
return;
|
||||
}
|
||||
const meta = {
|
||||
traceId: getHeader(request, "x-trace-id") || `trc_gateway_poll_${randomUUID()}`,
|
||||
actorId: `svc_${session.serviceId}`,
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: runtimeEnvironment(env)
|
||||
};
|
||||
|
||||
try {
|
||||
await runtimeStore.registerGatewaySession?.({
|
||||
projectId: session.projectId,
|
||||
gatewaySessionId: session.gatewaySessionId,
|
||||
serviceId: "hwlab-gateway",
|
||||
gatewayId: session.gatewayId,
|
||||
endpoint: session.endpoint,
|
||||
status: "connected",
|
||||
labels: {
|
||||
demoOutboundPoll: "true",
|
||||
os: String(session.system?.platform ?? "unknown")
|
||||
}
|
||||
}, meta);
|
||||
await runtimeStore.registerBoxResource?.({
|
||||
projectId: session.projectId,
|
||||
gatewaySessionId: session.gatewaySessionId,
|
||||
resourceId: session.resourceId,
|
||||
boxId: session.boxId,
|
||||
resourceType: "simulator_endpoint",
|
||||
state: "available",
|
||||
metadata: {
|
||||
serviceId: "hwlab-gateway",
|
||||
demoShellHost: true
|
||||
}
|
||||
}, meta);
|
||||
if (session.capabilities.length > 0) {
|
||||
await runtimeStore.reportBoxCapabilities?.({
|
||||
capabilities: session.capabilities.map((capability) => ({
|
||||
...capability,
|
||||
resourceId: capability.resourceId ?? session.resourceId,
|
||||
projectId: capability.projectId ?? session.projectId
|
||||
}))
|
||||
}, meta);
|
||||
}
|
||||
session.runtimeRegisteredAt = new Date().toISOString();
|
||||
session.runtimeRegisteredEpochMs = Date.now();
|
||||
} catch {
|
||||
// Demo polling must keep the gateway online even when durable runtime writes are not ready.
|
||||
}
|
||||
}
|
||||
|
||||
function sendRestError(request, response, statusCode, code, message, context = {}) {
|
||||
const requestId = getHeader(request, "x-request-id") || "req_unassigned";
|
||||
sendJson(response, statusCode, {
|
||||
error: {
|
||||
code,
|
||||
message,
|
||||
reason: context.reason,
|
||||
audit: createAuditRecord({
|
||||
requestId,
|
||||
actor: {
|
||||
type: "user",
|
||||
id: getHeader(request, "x-actor-id") || `system_${CLOUD_API_SERVICE_ID}`
|
||||
},
|
||||
source: {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: runtimeEnvironment(options.env ?? process.env),
|
||||
traceId: getHeader(request, "x-trace-id"),
|
||||
adapter: "rest"
|
||||
},
|
||||
operation: context.operation || `${request.method || "GET"} ${request.url || "/"}`,
|
||||
target: context.target || {
|
||||
type: "route",
|
||||
id: request.url || "/"
|
||||
},
|
||||
result: context.result || "failed"
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function statusForRpcResponse(rpcResponse) {
|
||||
if (!rpcResponse.error) {
|
||||
return 200;
|
||||
}
|
||||
|
||||
if (rpcResponse.error.code === ERROR_CODES.invalidRequest || rpcResponse.error.code === ERROR_CODES.parseError) {
|
||||
return 400;
|
||||
}
|
||||
|
||||
if (rpcResponse.error.code === ERROR_CODES.methodNotFound) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
return 200;
|
||||
}
|
||||
|
||||
function readBody(request, limitBytes = DEFAULT_BODY_LIMIT_BYTES) {
|
||||
const limit = Number.isInteger(limitBytes) && limitBytes > 0 ? limitBytes : DEFAULT_BODY_LIMIT_BYTES;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let body = "";
|
||||
request.setEncoding("utf8");
|
||||
|
||||
request.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (Buffer.byteLength(body, "utf8") > limit) {
|
||||
request.destroy(new Error(`request body exceeds ${limit} bytes`));
|
||||
}
|
||||
});
|
||||
|
||||
request.on("end", () => resolve(body));
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(response, statusCode, body) {
|
||||
response.writeHead(statusCode, {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"cache-control": "no-store"
|
||||
});
|
||||
response.end(`${JSON.stringify(body)}\n`);
|
||||
}
|
||||
|
||||
function getHeader(request, name) {
|
||||
const value = request.headers[name.toLowerCase()];
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function firstHeaderValue(request, name) {
|
||||
return getHeader(request, name);
|
||||
}
|
||||
|
||||
function truthyFlag(value) {
|
||||
return /^(?:1|true|yes|on|enabled)$/iu.test(String(value ?? "").trim());
|
||||
}
|
||||
|
||||
function safeTraceId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^trc_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function safeSessionId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function safeConversationId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^cnv_[A-Za-z0-9_.:-]+$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function safeOpaqueId(value) {
|
||||
const text = String(value ?? "").trim();
|
||||
return /^[A-Za-z0-9_.:-]{3,180}$/u.test(text) ? text : null;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function positiveInteger(value, fallback) {
|
||||
const parsed = Number.parseInt(value ?? "", 10);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
async function runtimeReadiness(runtimeStore) {
|
||||
if (typeof runtimeStore?.readiness === "function") {
|
||||
return runtimeStore.readiness();
|
||||
}
|
||||
return runtimeStore.summary();
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { test } from "bun:test";
|
||||
|
||||
import { handleJsonRpcRequest } from "../cloud/json-rpc.mjs";
|
||||
import { handleJsonRpcRequest } from "../cloud/json-rpc.ts";
|
||||
import { ERROR_CODES, validateResponse } from "../protocol/index.mjs";
|
||||
import {
|
||||
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
buildPostgresPoolConfig,
|
||||
createCloudRuntimeStore,
|
||||
createConfiguredCloudRuntimeStore
|
||||
} from "./runtime-store.mjs";
|
||||
} from "./runtime-store.ts";
|
||||
import {
|
||||
CLOUD_CORE_MIGRATION_ID,
|
||||
CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
||||
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS
|
||||
} from "./schema.mjs";
|
||||
} from "./schema.ts";
|
||||
|
||||
test("memory runtime store is never marked durable", () => {
|
||||
const store = createCloudRuntimeStore();
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
||||
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
||||
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS
|
||||
} from "./schema.mjs";
|
||||
} from "./schema.ts";
|
||||
|
||||
export const RUNTIME_STORE_KIND = "memory";
|
||||
export const RUNTIME_STORE_KIND_POSTGRES = "postgres";
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { test } from "bun:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { TABLES, assertProtocolRecord, assertProtocolRecords } from "../protocol/index.mjs";
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
FROZEN_CLOUD_CORE_TABLES,
|
||||
assertFrozenCloudCoreTables,
|
||||
requiredRuntimeDurableColumns
|
||||
} from "./schema.mjs";
|
||||
} from "./schema.ts";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
+15
-15
File diff suppressed because one or more lines are too long
@@ -825,18 +825,43 @@ function shortRevision(value) {
|
||||
return source.length >= 7 ? source.slice(0, 7) : source || "unknown";
|
||||
}
|
||||
|
||||
function runNodeEntrypoint(file) {
|
||||
const child = spawn(process.execPath, [file], {
|
||||
function runEntrypoint(command, args) {
|
||||
const child = spawn(command, args, {
|
||||
cwd: "/app",
|
||||
env: process.env,
|
||||
stdio: "inherit"
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(JSON.stringify({
|
||||
serviceId,
|
||||
status: "failed",
|
||||
error: "entrypoint_spawn_failed",
|
||||
command,
|
||||
reason: error.message
|
||||
}) + "\n");
|
||||
process.exit(127);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
else process.exit(code ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
function runNodeEntrypoint(file) {
|
||||
runEntrypoint(process.execPath, [file]);
|
||||
}
|
||||
|
||||
function runBunEntrypoint(file) {
|
||||
const candidates = [
|
||||
process.env.HWLAB_BUN_COMMAND,
|
||||
"/app/node_modules/.bin/bun",
|
||||
"/usr/local/bin/bun",
|
||||
"bun"
|
||||
].filter(Boolean);
|
||||
const command = candidates.find((candidate) => !candidate.includes("/") || existsSync(candidate)) || "bun";
|
||||
runEntrypoint(command, [file]);
|
||||
}
|
||||
|
||||
function cookieValue(request, name) {
|
||||
const raw = request.headers.cookie || "";
|
||||
for (const part of raw.split(";")) {
|
||||
@@ -1128,6 +1153,8 @@ function serveHealthOnly() {
|
||||
|
||||
if (runtimeKind === "node-command" && entrypoint) {
|
||||
runNodeEntrypoint(entrypoint);
|
||||
} else if (runtimeKind === "bun-command" && entrypoint) {
|
||||
runBunEntrypoint(entrypoint);
|
||||
} else if (runtimeKind === "cloud-web") {
|
||||
await serveCloudWeb();
|
||||
} else {
|
||||
@@ -1138,11 +1165,12 @@ if (runtimeKind === "node-command" && entrypoint) {
|
||||
}
|
||||
|
||||
function dockerfile(baseImage, port, labels = []) {
|
||||
const dependencyTimingScript = `started_at=$(node -e "console.log(Date.now())"); status=succeeded; if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; fi && if [ -f package-lock.json ]; then npm install --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi && codex_version="$(node -p "require('./node_modules/@openai/codex/package.json').version")" && case "$(uname -m)" in x86_64|amd64) test -e node_modules/@openai/codex-linux-x64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-x64@npm:@openai/codex@\${codex_version}-linux-x64" ;; aarch64|arm64) test -e node_modules/@openai/codex-linux-arm64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-arm64@npm:@openai/codex@\${codex_version}-linux-arm64" ;; esac || status=failed; finished_at=$(node -e "console.log(Date.now())"); node -e "console.log(JSON.stringify({event:'g14-cicd-build-step-timing',stage:'dependency-install',status:process.argv[1],durationMs:Number(process.argv[3])-Number(process.argv[2]),source:'dockerfile-run'}))" "$status" "$started_at" "$finished_at"; test "$status" = succeeded`;
|
||||
const dependencyTimingScript = `started_at=$(node -e "console.log(Date.now())"); status=succeeded; if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; fi && if [ -f package-lock.json ]; then npm install --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi && if [ "\${HWLAB_ARTIFACT_KIND:-}" = "bun-command" ]; then npm install --no-save --omit=dev --include=optional --ignore-scripts bun@1.3.13; fi && codex_version="$(node -p "require('./node_modules/@openai/codex/package.json').version")" && case "$(uname -m)" in x86_64|amd64) test -e node_modules/@openai/codex-linux-x64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-x64@npm:@openai/codex@\${codex_version}-linux-x64" ;; aarch64|arm64) test -e node_modules/@openai/codex-linux-arm64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-arm64@npm:@openai/codex@\${codex_version}-linux-arm64" ;; esac || status=failed; finished_at=$(node -e "console.log(Date.now())"); node -e "console.log(JSON.stringify({event:'g14-cicd-build-step-timing',stage:'dependency-install',status:process.argv[1],durationMs:Number(process.argv[3])-Number(process.argv[2]),source:'dockerfile-run'}))" "$status" "$started_at" "$finished_at"; test "$status" = succeeded`;
|
||||
return [
|
||||
`FROM ${baseImage}`,
|
||||
"WORKDIR /app",
|
||||
...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`),
|
||||
"ARG HWLAB_ARTIFACT_KIND",
|
||||
"ENV CODEX_HOME=/codex-home",
|
||||
"ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home",
|
||||
"ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab",
|
||||
@@ -1167,7 +1195,6 @@ function dockerfile(baseImage, port, labels = []) {
|
||||
`RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`,
|
||||
"ARG HWLAB_ENVIRONMENT",
|
||||
"ARG HWLAB_SERVICE_ID",
|
||||
"ARG HWLAB_ARTIFACT_KIND",
|
||||
"ARG HWLAB_SERVICE_ENTRYPOINT",
|
||||
"ARG HWLAB_COMMIT_ID",
|
||||
"ARG HWLAB_REVISION",
|
||||
@@ -1304,8 +1331,8 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre
|
||||
blocker({
|
||||
type: "runtime_blocker",
|
||||
scope: service.serviceId,
|
||||
summary: `${service.serviceId} has no cmd/${service.serviceId}/main.mjs runtime entrypoint; a health-only placeholder image was not built as a real implementation.`,
|
||||
next: `Add cmd/${service.serviceId}/main.mjs or a dedicated Dockerfile for ${service.serviceId}.`
|
||||
summary: `${service.serviceId} has no runtime entrypoint; a health-only placeholder image was not built as a real implementation.`,
|
||||
next: `Add cmd/${service.serviceId}/main.mjs, cmd/${service.serviceId}/main.ts, or a dedicated Dockerfile for ${service.serviceId}.`
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:net";
|
||||
|
||||
import { createCloudApiServer } from "../internal/cloud/server.mjs";
|
||||
import { createCloudApiServer } from "../internal/cloud/server.ts";
|
||||
import { ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
|
||||
|
||||
const previousEnv = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import assert from "node:assert/strict";
|
||||
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
@@ -9,12 +9,12 @@ import path from "node:path";
|
||||
import {
|
||||
handleCodeAgentChat,
|
||||
validateCodeAgentChatSchema
|
||||
} from "../internal/cloud/code-agent-chat.mjs";
|
||||
} from "../internal/cloud/code-agent-chat.ts";
|
||||
import {
|
||||
codexAppServerArgs,
|
||||
codexAppServerProviderBaseUrl,
|
||||
createCodexStdioSessionManager
|
||||
} from "../internal/cloud/codex-stdio-session.mjs";
|
||||
} from "../internal/cloud/codex-stdio-session.ts";
|
||||
import {
|
||||
classifyCodexRunnerCapability,
|
||||
classifyCodeAgentChatReadiness,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
runDeployContractPlanCli,
|
||||
writeDeployContractPlanError
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
runDeployDesiredStatePlanCli,
|
||||
writeDeployDesiredStatePlanError
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
formatDevRuntimeMigrationFailure,
|
||||
runDevRuntimeMigrationCli
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import {
|
||||
formatDevRuntimeProvisioningFailure,
|
||||
runDevRuntimeProvisioningCli
|
||||
|
||||
@@ -67,7 +67,7 @@ test("component model uses built-in service paths", () => {
|
||||
"skills/",
|
||||
"tools/"
|
||||
]);
|
||||
assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.mjs"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.mjs"]);
|
||||
assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]);
|
||||
});
|
||||
|
||||
test("planner remains compatible with deploy.k3s.serviceMappings shape", async () => {
|
||||
@@ -103,7 +103,7 @@ async function createFixtureRepo(options = {}) {
|
||||
}
|
||||
await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module" }, null, 2));
|
||||
await writeFile(path.join(repo, "package-lock.json"), JSON.stringify({ lockfileVersion: 3 }, null, 2));
|
||||
await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.mjs"), "console.log('api');\n");
|
||||
await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api');\n");
|
||||
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.mjs"), "console.log('bridge');\n");
|
||||
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.mjs"), "console.log('test');\n");
|
||||
await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "<html></html>\n");
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import { spawn } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { existsSync } from "node:fs";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
@@ -24,9 +26,10 @@ const commonEnv = {
|
||||
HWLAB_CLOUD_RUNTIME_DURABLE: "false"
|
||||
};
|
||||
const children = [];
|
||||
const bunCommand = resolveBunCommand();
|
||||
|
||||
try {
|
||||
const cloud = startNode("cmd/hwlab-cloud-api/main.mjs", {
|
||||
const cloud = startNode("cmd/hwlab-cloud-api/main.ts", {
|
||||
...commonEnv,
|
||||
HWLAB_CLOUD_API_HOST: "127.0.0.1",
|
||||
HWLAB_CLOUD_API_PORT: String(cloudPort),
|
||||
@@ -134,7 +137,8 @@ try {
|
||||
}
|
||||
|
||||
function startNode(entrypoint, env) {
|
||||
const child = spawn(process.execPath, [entrypoint], {
|
||||
const command = entrypoint.endsWith(".ts") ? bunCommand : process.execPath;
|
||||
const child = spawn(command, [entrypoint], {
|
||||
cwd,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
@@ -153,6 +157,15 @@ function startNode(entrypoint, env) {
|
||||
return child;
|
||||
}
|
||||
|
||||
function resolveBunCommand() {
|
||||
const candidates = [
|
||||
process.env.HWLAB_BUN_COMMAND,
|
||||
process.env.HOME ? path.join(process.env.HOME, ".bun/bin/bun") : null,
|
||||
"bun"
|
||||
].filter(Boolean);
|
||||
return candidates.find((candidate) => !candidate.includes("/") || existsSync(candidate)) || "bun";
|
||||
}
|
||||
|
||||
async function waitForGatewaySession(apiBaseUrl, gatewaySessionId) {
|
||||
const deadline = Date.now() + 8000;
|
||||
let last;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import assert from "node:assert/strict";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
@@ -8,11 +9,12 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
|
||||
import { validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.mjs";
|
||||
import { validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.ts";
|
||||
import { runCli } from "../tools/hwlab-cli/lib/cli.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const children = [];
|
||||
const bunCommand = resolveBunCommand();
|
||||
const CODEX_STDIO_FEASIBILITY_BLOCKERS = new Set([
|
||||
"codex_cli_binary_missing",
|
||||
"codex_stdio_supervisor_disabled",
|
||||
@@ -46,7 +48,8 @@ function freePort() {
|
||||
}
|
||||
|
||||
function startNode(relativeScript, env = {}) {
|
||||
const child = spawn(process.execPath, [relativeScript], {
|
||||
const command = relativeScript.endsWith(".ts") ? bunCommand : process.execPath;
|
||||
const child = spawn(command, [relativeScript], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -73,6 +76,15 @@ function startNode(relativeScript, env = {}) {
|
||||
return record;
|
||||
}
|
||||
|
||||
function resolveBunCommand() {
|
||||
const candidates = [
|
||||
process.env.HWLAB_BUN_COMMAND,
|
||||
process.env.HOME ? path.join(process.env.HOME, ".bun/bin/bun") : null,
|
||||
"bun"
|
||||
].filter(Boolean);
|
||||
return candidates.find((candidate) => !candidate.includes("/") || existsSync(candidate)) || "bun";
|
||||
}
|
||||
|
||||
function runNodeJson(relativeScript, args = [], env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [relativeScript, ...args], {
|
||||
@@ -184,7 +196,7 @@ function rpcEnvelope({ id, method, params = {} }) {
|
||||
|
||||
async function smokeCloudApi() {
|
||||
const port = await freePort();
|
||||
const service = startNode("cmd/hwlab-cloud-api/main.mjs", {
|
||||
const service = startNode("cmd/hwlab-cloud-api/main.ts", {
|
||||
HWLAB_CLOUD_API_HOST: "127.0.0.1",
|
||||
HWLAB_CLOUD_API_PORT: String(port),
|
||||
HWLAB_CLOUD_DB_URL: "",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||
process.stdout.write(JSON.stringify({
|
||||
script: "run-bun",
|
||||
usage: "node scripts/run-bun.mjs <bun-args...>",
|
||||
env: "HWLAB_BUN_COMMAND",
|
||||
resolvedCommand: resolveBunCommand()
|
||||
}, null, 2) + "\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const command = resolveBunCommand();
|
||||
if (!command) {
|
||||
process.stderr.write(JSON.stringify({
|
||||
script: "run-bun",
|
||||
status: "failed",
|
||||
error: "bun_command_not_found",
|
||||
checked: bunCandidates()
|
||||
}, null, 2) + "\n");
|
||||
process.exit(127);
|
||||
}
|
||||
|
||||
const child = spawn(command, args, {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(JSON.stringify({
|
||||
script: "run-bun",
|
||||
status: "failed",
|
||||
error: "bun_spawn_failed",
|
||||
command,
|
||||
reason: error.message
|
||||
}, null, 2) + "\n");
|
||||
process.exit(127);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
});
|
||||
|
||||
function resolveBunCommand() {
|
||||
for (const candidate of bunCandidates()) {
|
||||
if (!candidate) continue;
|
||||
if (!candidate.includes(path.sep)) return candidate;
|
||||
if (existsSync(candidate)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function bunCandidates() {
|
||||
return [
|
||||
process.env.HWLAB_BUN_COMMAND,
|
||||
path.join(process.cwd(), "node_modules", ".bin", process.platform === "win32" ? "bun.exe" : "bun"),
|
||||
path.join(os.homedir(), ".bun", "bin", process.platform === "win32" ? "bun.exe" : "bun"),
|
||||
"/root/.bun/bin/bun",
|
||||
"/usr/local/bin/bun",
|
||||
"bun"
|
||||
].filter(Boolean);
|
||||
}
|
||||
@@ -917,7 +917,7 @@ function summarizeCloudWebBuildFreshness(distFreshness, artifactCloudWeb) {
|
||||
async function inspectCodeAgentTimeoutReadiness(repoRoot) {
|
||||
const [appSource, cloudApiEntrySource] = await Promise.all([
|
||||
readFile(path.join(repoRoot, "web/hwlab-cloud-web/app.mjs"), "utf8"),
|
||||
readFile(path.join(repoRoot, "cmd/hwlab-cloud-api/main.mjs"), "utf8")
|
||||
readFile(path.join(repoRoot, "cmd/hwlab-cloud-api/main.ts"), "utf8")
|
||||
]);
|
||||
const frontendTimeoutMs = numericSourceConstant(appSource, "DEFAULT_CODE_AGENT_TIMEOUT_MS");
|
||||
const backendTimeoutMs = parseTimeoutFallbackMs(cloudApiEntrySource, "HWLAB_CODE_AGENT_TIMEOUT_MS");
|
||||
@@ -953,7 +953,7 @@ function parseTimeoutFallbackMs(source, envName) {
|
||||
async function inspectM3IoSourceReadiness(repoRoot) {
|
||||
const files = await listTrackedFiles(repoRoot);
|
||||
const candidateFiles = files.filter((file) =>
|
||||
/\.(?:mjs|js|json|md|html|css|yaml|yml)$/u.test(file) &&
|
||||
/\.(?:ts|mjs|js|json|md|html|css|yaml|yml)$/u.test(file) &&
|
||||
file !== "scripts/src/artifact-runtime-readiness-guard.mjs" &&
|
||||
file !== "scripts/artifact-runtime-readiness-guard.test.mjs"
|
||||
);
|
||||
@@ -971,7 +971,7 @@ async function inspectM3IoSourceReadiness(repoRoot) {
|
||||
const present = matches.length > 0;
|
||||
const sourceReady = contractFiles.length > 0 &&
|
||||
matches.some((file) => file === "web/hwlab-cloud-web/app.mjs") &&
|
||||
matches.some((file) => file === "internal/cloud/m3-io-control.mjs" || file === "scripts/src/m3-io-control-e2e.mjs");
|
||||
matches.some((file) => file === "internal/cloud/m3-io-control.ts" || file === "scripts/src/m3-io-control-e2e.mjs");
|
||||
return {
|
||||
status: present ? (sourceReady ? "pass" : "blocked") : "absent",
|
||||
present,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.ts";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
} from "../../internal/cloud/code-agent-contract.ts";
|
||||
import {
|
||||
HWLAB_M3_IO_API_BASE_URL_ENV,
|
||||
HWLAB_M3_IO_DEV_SERVICE_BASE_URL
|
||||
@@ -361,10 +361,10 @@ function validateCloudApiDbSource(ctx, env) {
|
||||
"cloud API DEV DB SSL mode"
|
||||
);
|
||||
expectEqual(ctx, env.HWLAB_CLOUD_DB_CONTRACT, "dev-redacted-presence-only", "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_CONTRACT", "cloud API DB redacted contract marker");
|
||||
expectEqual(ctx, DEV_DB_ENV_CONTRACT.endpointAuthority.source, "secret-url-host", "internal/cloud/db-contract.mjs.endpointAuthority.source", "cloud API DB runtime authority source");
|
||||
expectEqual(ctx, DEV_DB_ENV_CONTRACT.endpointAuthority.env, "HWLAB_CLOUD_DB_URL", "internal/cloud/db-contract.mjs.endpointAuthority.env", "cloud API DB runtime authority env");
|
||||
expectEqual(ctx, alias.requiredForReadiness, false, "internal/cloud/db-contract.mjs.dns.requiredForReadiness", "cloud API DB DNS alias readiness requirement");
|
||||
expectEqual(ctx, alias.usedForProbe, false, "internal/cloud/db-contract.mjs.dns.usedForProbe", "cloud API DB DNS alias probe source");
|
||||
expectEqual(ctx, DEV_DB_ENV_CONTRACT.endpointAuthority.source, "secret-url-host", "internal/cloud/db-contract.ts.endpointAuthority.source", "cloud API DB runtime authority source");
|
||||
expectEqual(ctx, DEV_DB_ENV_CONTRACT.endpointAuthority.env, "HWLAB_CLOUD_DB_URL", "internal/cloud/db-contract.ts.endpointAuthority.env", "cloud API DB runtime authority env");
|
||||
expectEqual(ctx, alias.requiredForReadiness, false, "internal/cloud/db-contract.ts.dns.requiredForReadiness", "cloud API DB DNS alias readiness requirement");
|
||||
expectEqual(ctx, alias.usedForProbe, false, "internal/cloud/db-contract.ts.dns.usedForProbe", "cloud API DB DNS alias probe source");
|
||||
for (const name of [
|
||||
"HWLAB_CLOUD_DB_SERVICE_NAME",
|
||||
"HWLAB_CLOUD_DB_SERVICE_NAMESPACE",
|
||||
|
||||
@@ -7,8 +7,8 @@ import { promisify } from "node:util";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
inspectCodeAgentProviderManifestRefs
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
||||
} from "../../internal/cloud/code-agent-contract.ts";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.ts";
|
||||
import { tempReportPath } from "./report-paths.mjs";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -63,6 +63,19 @@ function serviceSourceState(catalog, serviceId) {
|
||||
|
||||
export async function resolveDevArtifactService(repoRoot, serviceId, catalog = null) {
|
||||
const sourceState = serviceSourceState(catalog, serviceId);
|
||||
if (serviceId === "hwlab-cloud-api") {
|
||||
const tsCmdPath = `cmd/${serviceId}/main.ts`;
|
||||
if (await pathExists(repoRoot, tsCmdPath)) {
|
||||
return withServicePublishPolicy({
|
||||
serviceId,
|
||||
runtimeKind: "bun-command",
|
||||
entrypoint: tsCmdPath,
|
||||
implementationState: "repo-entrypoint",
|
||||
sourceState
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cmdPath = `cmd/${serviceId}/main.mjs`;
|
||||
if (await pathExists(repoRoot, cmdPath)) {
|
||||
return withServicePublishPolicy({
|
||||
|
||||
@@ -1640,7 +1640,7 @@ function readStaticFiles() {
|
||||
runtime: readText("web/hwlab-cloud-web/runtime.mjs"),
|
||||
help: readText("web/hwlab-cloud-web/help.md"),
|
||||
artifactPublisher: readText("scripts/artifact-publish.mjs"),
|
||||
cloudApiServer: readText("internal/cloud/server.mjs"),
|
||||
cloudApiServer: readText("internal/cloud/server.ts"),
|
||||
devicePodData: readText("internal/device-pod/fake-data.mjs"),
|
||||
devicePodService: readText("cmd/hwlab-device-pod/main.mjs"),
|
||||
protocol: readText("internal/protocol/index.mjs"),
|
||||
|
||||
@@ -8,11 +8,11 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
DEV_DB_ENV_CONTRACT,
|
||||
summarizeDbContract
|
||||
} from "../../internal/cloud/db-contract.mjs";
|
||||
} from "../../internal/cloud/db-contract.ts";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
} from "../../internal/cloud/code-agent-contract.ts";
|
||||
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
|
||||
@@ -187,7 +187,7 @@ async function createDevGateReport(edgeHealth) {
|
||||
"node scripts/m1-contract-smoke.mjs"
|
||||
],
|
||||
evidence: [
|
||||
"npm run check includes internal/cloud/server.test.mjs and validates /health plus /health/live."
|
||||
"npm run check includes internal/cloud/server-health.test.ts and validates /health plus /health/live."
|
||||
],
|
||||
summary: "Local cloud-api health checks pass and include service, commit, and image evidence fields."
|
||||
},
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
DEV_DB_ENV_CONTRACT,
|
||||
buildDbHealthContract,
|
||||
summarizeDbContract
|
||||
} from "../../internal/cloud/db-contract.mjs";
|
||||
} from "../../internal/cloud/db-contract.ts";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../../internal/cloud/code-agent-contract.mjs";
|
||||
} from "../../internal/cloud/code-agent-contract.ts";
|
||||
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
|
||||
import { probeRegistryCapabilities } from "./registry-capabilities.mjs";
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_MISSING,
|
||||
RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
|
||||
RUNTIME_STORE_KIND_POSTGRES
|
||||
} from "../../internal/db/runtime-store.mjs";
|
||||
import { CLOUD_CORE_MIGRATION_ID } from "../../internal/db/schema.mjs";
|
||||
} from "../../internal/db/runtime-store.ts";
|
||||
import { CLOUD_CORE_MIGRATION_ID } from "../../internal/db/schema.ts";
|
||||
import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
@@ -7,8 +7,8 @@ const defaultKubeconfig = "/etc/rancher/k3s/k3s.yaml";
|
||||
const defaultNamespace = "hwlab-dev";
|
||||
const defaultDeployment = "hwlab-cloud-api";
|
||||
const defaultConfigMap = "hwlab-cloud-api-code-agent-hotfix";
|
||||
const defaultMountPath = "/app/internal/cloud/code-agent-chat.mjs";
|
||||
const defaultSubPath = "code-agent-chat.mjs";
|
||||
const defaultMountPath = "/app/internal/cloud/code-agent-chat.ts";
|
||||
const defaultSubPath = "code-agent-chat.ts";
|
||||
const defaultMarker = "runtime-hotfix-pc-gateway-shell";
|
||||
const defaultAnnotationKey = "hwlab.pikastech.local/pc-gateway-shell-hotfix";
|
||||
const defaultPodLabelSelector = "app.kubernetes.io/name=hwlab-cloud-api";
|
||||
|
||||
@@ -87,7 +87,7 @@ function fixtureResult(args, options = {}) {
|
||||
if (options.noHotfix) {
|
||||
return { ok: false, exitCode: 1, stdout: "", stderr: "Error from server (NotFound): configmaps \"hwlab-cloud-api-code-agent-hotfix\" not found" };
|
||||
}
|
||||
return okJson({ data: { "code-agent-chat.mjs": "// runtime-hotfix-pc-gateway-shell" } });
|
||||
return okJson({ data: { "code-agent-chat.ts": "// runtime-hotfix-pc-gateway-shell" } });
|
||||
}
|
||||
if (text.includes("get deployment")) {
|
||||
return okJson(deploymentFixture({ noHotfix: options.noHotfix }));
|
||||
@@ -127,7 +127,7 @@ function deploymentFixture({ noHotfix = false } = {}) {
|
||||
name: "code-agent-hotfix",
|
||||
configMap: {
|
||||
name: "hwlab-cloud-api-code-agent-hotfix",
|
||||
items: [{ key: "code-agent-chat.mjs", path: "code-agent-chat.mjs" }]
|
||||
items: [{ key: "code-agent-chat.ts", path: "code-agent-chat.ts" }]
|
||||
}
|
||||
}],
|
||||
containers: [{
|
||||
@@ -135,8 +135,8 @@ function deploymentFixture({ noHotfix = false } = {}) {
|
||||
image: "127.0.0.1:5000/hwlab/hwlab-cloud-api:dev",
|
||||
volumeMounts: noHotfix ? [] : [{
|
||||
name: "code-agent-hotfix",
|
||||
mountPath: "/app/internal/cloud/code-agent-chat.mjs",
|
||||
subPath: "code-agent-chat.mjs",
|
||||
mountPath: "/app/internal/cloud/code-agent-chat.ts",
|
||||
subPath: "code-agent-chat.ts",
|
||||
readOnly: true
|
||||
}]
|
||||
}]
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
||||
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE_COLUMNS,
|
||||
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS
|
||||
} from "../../internal/db/schema.mjs";
|
||||
} from "../../internal/db/schema.ts";
|
||||
import {
|
||||
PostgresCloudRuntimeStore,
|
||||
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
buildPostgresPoolConfig
|
||||
} from "../../internal/db/runtime-store.mjs";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
||||
} from "../../internal/db/runtime-store.ts";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.ts";
|
||||
import { ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
|
||||
|
||||
|
||||
@@ -12,10 +12,11 @@ import {
|
||||
CLOUD_CORE_MIGRATION_ID,
|
||||
CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
||||
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS
|
||||
} from "../../internal/db/schema.mjs";
|
||||
} from "../../internal/db/schema.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
|
||||
const bunCommand = process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun");
|
||||
|
||||
test("source check is non-secret and does not attempt live DB access", async () => {
|
||||
const report = await buildDevRuntimeMigrationReport(parseArgs(["--check"]), {
|
||||
@@ -41,8 +42,8 @@ test("source check is non-secret and does not attempt live DB access", async ()
|
||||
|
||||
test("cloud-api image migration entrypoint exposes the same non-secret source check", async () => {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
["cmd/hwlab-cloud-api/migrate.mjs", "--check"],
|
||||
bunCommand,
|
||||
["cmd/hwlab-cloud-api/migrate.ts", "--check"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
@@ -67,7 +68,7 @@ test("cloud-api image migration entrypoint exposes the same non-secret source ch
|
||||
test("cloud-api image migration entrypoint redacts invalid DSN-shaped argv failures", async () => {
|
||||
const secretArg = "postgresql://hwlab:secret-password@db.example.test:5432/hwlab?sslmode=require";
|
||||
await assert.rejects(
|
||||
execFileAsync(process.execPath, ["cmd/hwlab-cloud-api/migrate.mjs", secretArg], { cwd: repoRoot }),
|
||||
execFileAsync(bunCommand, ["cmd/hwlab-cloud-api/migrate.ts", secretArg], { cwd: repoRoot }),
|
||||
(error) => {
|
||||
const report = JSON.parse(error.stdout);
|
||||
assert.equal(report.conclusion.status, "blocked");
|
||||
|
||||
@@ -3,7 +3,7 @@ import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.ts";
|
||||
import {
|
||||
PostgresCloudRuntimeStore,
|
||||
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
buildPostgresPoolConfig
|
||||
} from "../../internal/db/runtime-store.mjs";
|
||||
} from "../../internal/db/runtime-store.ts";
|
||||
import { ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "../..");
|
||||
const bunCommand = process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun");
|
||||
|
||||
test("source check parses target role/database without leaking DB URL material", async () => {
|
||||
const report = await buildDevRuntimeProvisioningReport(parseArgs(["--check"]), {
|
||||
@@ -33,8 +34,8 @@ test("source check parses target role/database without leaking DB URL material",
|
||||
|
||||
test("cloud-api image provisioning entrypoint exposes non-secret source check", async () => {
|
||||
const { stdout } = await execFileAsync(
|
||||
process.execPath,
|
||||
["cmd/hwlab-cloud-api/provision.mjs", "--check"],
|
||||
bunCommand,
|
||||
["cmd/hwlab-cloud-api/provision.ts", "--check"],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
|
||||
@@ -353,6 +353,7 @@ function reuseCandidate(catalogRecord, affected) {
|
||||
}
|
||||
|
||||
function runtimeKindForService(serviceId) {
|
||||
if (serviceId === "hwlab-cloud-api") return "bun-command";
|
||||
if (serviceId === "hwlab-cloud-web") return "cloud-web";
|
||||
if (serviceId === "hwlab-cli") return "cli";
|
||||
if (serviceId === "hwlab-agent-skills") return "skills-bundle";
|
||||
@@ -360,6 +361,7 @@ function runtimeKindForService(serviceId) {
|
||||
}
|
||||
|
||||
function entrypointForService(serviceId) {
|
||||
if (serviceId === "hwlab-cloud-api") return "cmd/hwlab-cloud-api/main.ts";
|
||||
if (serviceId === "hwlab-cloud-web") return "web/hwlab-cloud-web/index.html";
|
||||
if (serviceId === "hwlab-cli") return "tools/hwlab-cli/bin/hwlab-cli.mjs";
|
||||
if (serviceId === "hwlab-agent-skills") return "skills/hwlab-agent-runtime/SKILL.md";
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
M3_IO_CONTROL_CONTRACT_VERSION,
|
||||
M3_IO_CONTROL_ROUTE,
|
||||
describeM3IoControl
|
||||
} from "../../internal/cloud/m3-io-control.mjs";
|
||||
} from "../../internal/cloud/m3-io-control.ts";
|
||||
|
||||
export const M3_IO_E2E_CONTRACT_VERSION = "m3-io-control-e2e-v1";
|
||||
export const defaultFrontendUrl = "http://74.48.78.17:16666/";
|
||||
@@ -225,9 +225,9 @@ export async function buildM3IoControlSourceReport(options = {}) {
|
||||
|
||||
export async function runM3IoControlSourceChecks({ repoRoot: root = repoRoot } = {}) {
|
||||
const [serverSource, jsonRpcSource, m3Source, appSource, htmlSource, artifactPublisherSource, cloudWebRouteSource] = await Promise.all([
|
||||
readRepoText(root, "internal/cloud/server.mjs"),
|
||||
readRepoText(root, "internal/cloud/json-rpc.mjs"),
|
||||
readRepoText(root, "internal/cloud/m3-io-control.mjs"),
|
||||
readRepoText(root, "internal/cloud/server.ts"),
|
||||
readRepoText(root, "internal/cloud/json-rpc.ts"),
|
||||
readRepoText(root, "internal/cloud/m3-io-control.ts"),
|
||||
readRepoText(root, "web/hwlab-cloud-web/app.mjs"),
|
||||
readRepoText(root, "web/hwlab-cloud-web/index.html"),
|
||||
readRepoText(root, "scripts/artifact-publish.mjs"),
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import {
|
||||
M3_IO_CHAIN,
|
||||
M3_IO_CONTROL_ROUTE
|
||||
} from "../../internal/cloud/m3-io-control.mjs";
|
||||
} from "../../internal/cloud/m3-io-control.ts";
|
||||
import {
|
||||
buildM3IoControlSourceReport,
|
||||
runM3IoControlLiveReport
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import assert from "node:assert/strict";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -6,11 +6,11 @@ import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
DEV_DB_ENV_CONTRACT,
|
||||
parseDbUrlContract
|
||||
} from "../internal/cloud/db-contract.mjs";
|
||||
} from "../internal/cloud/db-contract.ts";
|
||||
import {
|
||||
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
||||
codeAgentSecretRefPlaceholder
|
||||
} from "../internal/cloud/code-agent-contract.mjs";
|
||||
} from "../internal/cloud/code-agent-contract.ts";
|
||||
import {
|
||||
HWLAB_M3_IO_API_BASE_URL_ENV,
|
||||
HWLAB_M3_IO_DEV_SERVICE_BASE_URL
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
#!/usr/bin/env bun
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../internal/cloud/db-contract.mjs";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../internal/cloud/db-contract.ts";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
@@ -215,12 +215,12 @@ function assertDurableRuntimeRunbook(value) {
|
||||
"Moved:",
|
||||
"Still blocked:",
|
||||
"No full M3, M4, or M5 acceptance is allowed while runtime durability is blocked",
|
||||
"node cmd/hwlab-cloud-api/provision.mjs --check",
|
||||
"node cmd/hwlab-cloud-api/provision.mjs --dry-run --allow-live-db-read --confirm-dev --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json",
|
||||
"node cmd/hwlab-cloud-api/provision.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json",
|
||||
"node cmd/hwlab-cloud-api/migrate.mjs --check",
|
||||
"node cmd/hwlab-cloud-api/migrate.mjs --dry-run --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json",
|
||||
"node cmd/hwlab-cloud-api/migrate.mjs --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json",
|
||||
"bun cmd/hwlab-cloud-api/provision.ts --check",
|
||||
"bun cmd/hwlab-cloud-api/provision.ts --dry-run --allow-live-db-read --confirm-dev --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json",
|
||||
"bun cmd/hwlab-cloud-api/provision.ts --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json",
|
||||
"bun cmd/hwlab-cloud-api/migrate.ts --check",
|
||||
"bun cmd/hwlab-cloud-api/migrate.ts --dry-run --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json",
|
||||
"bun cmd/hwlab-cloud-api/migrate.ts --apply --confirm-dev --confirmed-non-production --report /tmp/hwlab-dev-gate/dev-runtime-migration-report.json",
|
||||
"DEV CD runtime migration Job uses",
|
||||
"node scripts/dev-runtime-provisioning.mjs --check",
|
||||
"node scripts/dev-runtime-provisioning.mjs --dry-run --allow-live-db-read --confirm-dev --report /tmp/hwlab-dev-gate/dev-runtime-provisioning-report.json",
|
||||
@@ -247,11 +247,11 @@ function assertCloudApiImageRuntimeEntrypoints({ packageJson, artifactPublisher,
|
||||
assertIncludes(artifactPublisher, expected, `${label} artifact publisher`);
|
||||
}
|
||||
for (const expected of [
|
||||
"cmd/hwlab-cloud-api/provision.mjs",
|
||||
"cmd/hwlab-cloud-api/migrate.mjs"
|
||||
"cmd/hwlab-cloud-api/provision.ts",
|
||||
"cmd/hwlab-cloud-api/migrate.ts"
|
||||
]) {
|
||||
assertIncludes(packageJson.scripts?.check, `node --check ${expected}`, `${label} package check`);
|
||||
assertIncludes(packageJson.scripts?.check, `node ${expected} --check`, `${label} package runtime check`);
|
||||
assertIncludes(packageJson.scripts?.check, `bun build ${expected} --target=bun --packages=external`, `${label} package check`);
|
||||
assertIncludes(packageJson.scripts?.check, `bun ${expected} --check`, `${label} package runtime check`);
|
||||
}
|
||||
for (const expected of ["scripts/g14-artifact-publish.mjs", "runtime-dev", "runtime-prod"]) {
|
||||
assertIncludes(gitopsRenderSource, expected, `${label} G14 GitOps render source`);
|
||||
|
||||
@@ -39,7 +39,7 @@ const helpMarkdown = readWeb("help.md");
|
||||
const faviconSvg = readWeb("favicon.svg");
|
||||
const faviconIco = readWeb("favicon.ico");
|
||||
const distContractScript = readWeb("scripts/dist-contract.mjs");
|
||||
const cloudApiServer = readRepo("internal/cloud/server.mjs");
|
||||
const cloudApiServer = readRepo("internal/cloud/server.ts");
|
||||
const devicePodData = readRepo("internal/device-pod/fake-data.mjs");
|
||||
const devicePodService = readRepo("cmd/hwlab-device-pod/main.mjs");
|
||||
const protocol = readRepo("internal/protocol/index.mjs");
|
||||
@@ -170,7 +170,7 @@ console.log("hwlab-cloud-web check ok: Device Pod summary sidebar, modal detail,
|
||||
function runJavaScriptSyntaxCheck() {
|
||||
const files = new Set([
|
||||
...collectJavaScriptFiles(rootDir),
|
||||
path.resolve(repoRoot, "internal/cloud/server.mjs"),
|
||||
path.resolve(repoRoot, "internal/cloud/server.ts"),
|
||||
path.resolve(repoRoot, "internal/device-pod/fake-data.mjs"),
|
||||
path.resolve(repoRoot, "cmd/hwlab-device-pod/main.mjs")
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user