diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 6529b1c..a924a93 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -36,7 +36,8 @@ ## Behavior / risk +State the user-visible behavior or compatibility/security risk. “No user-visible behavior change” is valid for an internal refactor. +No special commit-message marker or history rewrite is required. --> ## Validation diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d3affcd..b94ff70 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,73 +50,6 @@ jobs: BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} run: bun scripts/verify.ts full --step "${{ matrix.step }}" - policy-surface: - name: Policy surface - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - fetch-depth: 0 - - name: Require [policy] marker when control files change - env: - BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }} - HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} - run: | - set -euo pipefail - - # The control plane: deterministic gates and the rules that govern them. - # Editing any of these changes what every other check enforces, so the - # change must be explicit — a [policy] marker in the commit range. - patterns=' - .dependency-cruiser.cjs - sgconfig.yml - biome.json - tools/architecture/ - .githooks/ - .claude/hooks/ - .github/workflows/ - scripts/lint-changed.sh - scripts/verify.ts - scripts/verify.test.ts - scripts/pr-evidence.ts - scripts/pr-evidence.test.ts - scripts/release/ - scripts/setup-githooks.sh - ' - - base="$BASE_SHA" - # New branch / first push: no usable base; nothing to diff against. - if [ -z "$base" ] || ! git cat-file -e "$base^{commit}" 2>/dev/null; then - echo "No base commit to compare against; skipping policy-surface check." - exit 0 - fi - - changed=$(git diff --name-only "$base" "$HEAD_SHA") - touched="" - for p in $patterns; do - hit=$(printf '%s\n' "$changed" | grep -F "$p" || true) - if [ -n "$hit" ]; then - touched="$touched$hit"$'\n' - fi - done - - if [ -z "$touched" ]; then - echo "No control-plane files changed." - exit 0 - fi - - echo "Control-plane files changed:" - printf '%s' "$touched" | sed 's/^/ - /' - - if git log "$base..$HEAD_SHA" --format=%B | grep -qF '[policy]'; then - echo "Found [policy] marker in commit range. OK." - exit 0 - fi - - echo "::error::Control-plane files changed without a [policy] marker in any commit message." - echo "Add '[policy]' to a commit message to acknowledge changing the verification harness itself." - exit 1 - maintainer-evidence: name: Maintainer evidence runs-on: ubuntu-latest @@ -157,14 +90,13 @@ jobs: gate: name: Gate runs-on: ubuntu-latest - needs: [prepare-verification, verify, policy-surface, maintainer-evidence, package-compatibility] + needs: [prepare-verification, verify, maintainer-evidence, package-compatibility] if: always() steps: - name: Check results run: | if [ "${{ needs.prepare-verification.result }}" != "success" ] || \ [ "${{ needs.verify.result }}" != "success" ] || \ - [ "${{ needs.policy-surface.result }}" != "success" ] || \ [ "${{ needs.maintainer-evidence.result }}" != "success" ] || \ [ "${{ needs.package-compatibility.result }}" != "success" ]; then echo "One or more required checks failed." diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af6e792..792cbb1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,7 +39,7 @@ Every PR must satisfy: 2. At least one maintainer approval 3. No unresolved review comments -We keep the contribution path open: documentation, examples, and ordinary small changes do not need a design document or a special PR label. Changes to provider contracts, configuration/schema parsing, release/package files, or GitHub workflows have a small additional requirement: fill in the **Behavior / risk** and **Validation** sections of the PR description. This lets maintainers review the contract and its evidence before reading an implementation diff. +We keep the contribution path open: documentation, examples, and ordinary small changes do not need a design document, a special PR label, or a commit-message marker. Changes to provider contracts, configuration/schema parsing, release/package files, GitHub workflows, or verification policy have a small additional requirement: fill in the **Behavior / risk** and **Validation** sections of the PR description. This lets maintainers review the contract and its evidence before reading an implementation diff without asking contributors to rewrite commit history. A generated `bun.lock` change by itself is handled by audit and compatibility checks. Maintainers should enable the repository settings that make the same policy effective at merge time: require the `Gate` and `Analyze TypeScript` checks, require one approving review, dismiss stale approvals, require approval of the latest push, and require resolved conversations. CODEOWNERS review and a merge queue are intentionally not required for routine contributions at the current project stage. diff --git a/apps/server/src/lib/build-runtime-config.ts b/apps/server/src/lib/build-runtime-config.ts index fd462a7..9a5c70e 100644 --- a/apps/server/src/lib/build-runtime-config.ts +++ b/apps/server/src/lib/build-runtime-config.ts @@ -33,6 +33,10 @@ export async function buildRuntimeConfig(): Promise { // Every provider provisions a cloud sandbox environment; bailian additionally installs // `bailian-cli` in it. The vault is provider-conditional: bailian holds its DASHSCOPE_API_KEY; // other providers currently define no vault. + // + // Metadata stamps (`agents.base` / `agents.vault`) let the webui identify managed base + // resources via remote listing (findBaseEnvironment / findBaseVault), complementing the + // state-tracked identity the plan/apply engine provides. const vaults = vault ? { [vault.name]: { @@ -45,6 +49,7 @@ export async function buildRuntimeConfig(): Promise { secret_value: requireEnv(cred.secret_name), ...(cred.networking ? { networking: cred.networking } : {}), })), + metadata: { "agents.vault": "true" }, }, } : {}; @@ -52,6 +57,7 @@ export async function buildRuntimeConfig(): Promise { [environment.name]: { ...(environment.description ? { description: environment.description } : {}), config: environment.config, + metadata: { "agents.base": "true" }, }, }; diff --git a/apps/server/src/lib/state-scope.ts b/apps/server/src/lib/state-scope.ts index 4fb26f0..50ed0dc 100644 --- a/apps/server/src/lib/state-scope.ts +++ b/apps/server/src/lib/state-scope.ts @@ -1,16 +1,75 @@ -import { resolve } from "node:path"; +import { copyFileSync, existsSync, mkdirSync, renameSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; import { LocalFileStateBackend, type StateScope } from "@openagentpack/sdk"; import { RUNTIME_PROJECT_NAME } from "@/lib/build-runtime-config"; -// Historical on-disk state anchor. Previously derived via deriveStatePath() from the -// demo file examples/bailian/bailian-cli/agents.yaml → agents.state.json. We pin the default -// to that exact location so previously provisioned remote_ids (agents/vault/environment) -// keep resolving and are not re-created. Override with AGENTS_STATE_PATH. -const DEFAULT_STATE_PATH = "examples/bailian/bailian-cli/agents.state.json"; +/** + * Default playground state lives in the user home directory, alongside the + * provider config (~/.agents/config.json). This avoids colliding with CLI + * example configs that happen to live in the repo's examples/ directory. + * + * Override with AGENTS_STATE_PATH for custom deployment layouts. + */ +const DEFAULT_STATE_PATH = join(homedir(), ".agents", "playground.state.json"); + +/** + * Legacy state path. Before this migration the server stored its state inside + * the repo's example directory. We migrate it once so previously-provisioned + * remote_ids are preserved. + */ +const LEGACY_STATE_PATH = "examples/bailian/bailian-cli/agents.state.json"; + +let migrationChecked = false; + +/** + * One-time migration: if the legacy state file exists and the new default does + * not, copy it over and log a warning. Idempotent — subsequent calls after a + * successful migration (or when no migration is needed) are no-ops. A failed + * copy allows retry on the next call so transient I/O errors don't permanently + * disable migration for the process lifetime. + */ +function ensureMigrated(newPath: string, cwd: string): void { + if (migrationChecked) return; + + if (existsSync(newPath)) { + migrationChecked = true; + return; + } + const legacyPath = resolve(cwd, LEGACY_STATE_PATH); + if (!existsSync(legacyPath)) { + migrationChecked = true; + return; + } + + try { + mkdirSync(dirname(newPath), { recursive: true }); + copyFileSync(legacyPath, newPath); + // Rename the legacy file so a version rollback won't silently read stale state. + try { + renameSync(legacyPath, `${legacyPath}.migrated`); + } catch { + // Non-fatal: the copy succeeded, state is in the new location. + } + migrationChecked = true; + console.warn( + `[state] Migrated playground state from legacy path:\n` + + ` ${legacyPath}\n` + + ` → ${newPath}\n` + + ` The legacy file has been renamed to ${legacyPath}.migrated.`, + ); + } catch (error) { + // Don't set migrationChecked — allow retry on next call. + console.warn(`[state] Failed to migrate legacy state file: ${error instanceof Error ? error.message : error}`); + } +} export function resolveStatePath(env: NodeJS.ProcessEnv = process.env, cwd: string = process.cwd()): string { const configured = env.AGENTS_STATE_PATH?.trim(); - return configured ? resolve(cwd, configured) : resolve(cwd, DEFAULT_STATE_PATH); + if (configured) return resolve(cwd, configured); + + ensureMigrated(DEFAULT_STATE_PATH, cwd); + return DEFAULT_STATE_PATH; } export function deriveWebUiStateScope(): StateScope { diff --git a/apps/server/src/routes/vaults.ts b/apps/server/src/routes/vaults.ts index 2df759e..e6ceaba 100644 --- a/apps/server/src/routes/vaults.ts +++ b/apps/server/src/routes/vaults.ts @@ -68,8 +68,8 @@ vaultsRoute.openapi(createVaultRoute, async (c) => { const secretValue = key ?? (primarySecret ? requireEnv(primarySecret) : ""); const vault = await withAgentRuntime(DEFAULT_AGENT_ID, (ctx) => createCloudVault(ctx, name, { - // display_name carries the base-vault identity (Agents/secrets) — a vault has no - // separate `name` field, so findBaseVault nets it by display_name + the stamp. + // display_name is used as the vault's human-readable label on the provider. + // findBaseVault identifies the managed vault by its metadata stamp (agents.vault). display_name: name, metadata, credentials: structure.credentials.map((cred) => ({ diff --git a/apps/server/src/schemas/sessions.ts b/apps/server/src/schemas/sessions.ts index 5a7bd05..582247d 100644 --- a/apps/server/src/schemas/sessions.ts +++ b/apps/server/src/schemas/sessions.ts @@ -38,7 +38,7 @@ export const SessionDeleteResponseSchema = z }) .openapi("SessionDeleteResponse"); -// Mode A REST request ergonomics (server-owned input, not the shared DTO). +// REST request ergonomics (server-owned input, not the shared DTO). export const SessionsQuerySchema = z.object({ // Non-numeric values resolve to `undefined` so the handler clamps to a // default instead of the request being rejected with a 400. @@ -71,8 +71,7 @@ export const SessionParamsSchema = z.object({ export const CreateSessionBodySchema = z.object({ agentId: z.string(), prompt: z.string().min(1), - // Required: a session must be pinned to a cloud environment (sandbox). Both transports - // enforce this so Mode A (REST/OpenAPI) and Mode B (console) reject env-less creates. + // Required: a session must be pinned to a cloud environment (sandbox). environmentId: z.string().min(1), // Optional: cloud vault ids to bind a user-supplied credential so the sandbox receives it. // Top-level binding shape matches the console createSession. diff --git a/apps/server/src/schemas/vaults.ts b/apps/server/src/schemas/vaults.ts index 53c49ab..2095f03 100644 --- a/apps/server/src/schemas/vaults.ts +++ b/apps/server/src/schemas/vaults.ts @@ -10,9 +10,8 @@ export const CloudVaultsResponseSchema = z.object({ export const CreateVaultBodySchema = z.object({ name: z.string().min(1), metadata: z.record(z.string(), z.string()).optional(), - // The DASHSCOPE_API_KEY stored as the vault's credential secret value. Optional: Mode B - // supplies the user's key; Mode A (local) omits it and the server injects it from its own - // DASHSCOPE_API_KEY env. (Mode B never reaches this REST route — it goes via console RPC.) + // The DASHSCOPE_API_KEY stored as the vault's credential secret value. Optional: + // when omitted the server injects it from its own DASHSCOPE_API_KEY env. key: z.string().min(1).optional(), }); diff --git a/apps/server/src/services/agents/catalog.ts b/apps/server/src/services/agents/catalog.ts index 83dab79..1be7b05 100644 --- a/apps/server/src/services/agents/catalog.ts +++ b/apps/server/src/services/agents/catalog.ts @@ -3,6 +3,7 @@ import { DEFAULT_PLAYBOOK_PROVIDER, getDefaultPlaybook, getPlaybook, + getVaultProfile, type PlaybookTemplate, type ResolvedPlaybook, resolvePlaybookModel, @@ -80,9 +81,10 @@ export function compileAgentRuntime( const config = cloneConfig(baseConfig); const resolved = resolveSeedPlaybook(effectiveId, provider); const runtimeAgentId = resolved.agent.name; - const built = buildAgentDecl(undefined, toAgentBuildInput(resolved, provider, modelOverride)); - // The agent declares neither environment nor vault: base resources are provisioned eagerly - // and bound per-session via explicit environment_id + vault_ids. + const built = buildAgentDecl(undefined, toAgentBuildInput(resolved, provider, baseConfig, modelOverride)); + // The agent declares environment and vault so syncAgentResources manages them + // through the plan/apply engine — giving base resources state tracking, drift + // detection, and the same content-hash identity as agent/skill resources. config.agents = { ...(config.agents ?? {}), @@ -154,12 +156,45 @@ export function computeAgentConfigHash(config: ResolvedProjectConfig, agentId: s return createHash("sha256").update(stableStringify({ agentId, config })).digest("hex").slice(0, 16); } -function toAgentBuildInput(resolved: ResolvedPlaybook, provider: string, modelOverride?: string): AgentBuildInput { +function toAgentBuildInput( + resolved: ResolvedPlaybook, + provider: string, + baseConfig: ResolvedProjectConfig, + modelOverride?: string, +): AgentBuildInput { const model = resolvePlaybookModel(resolved, provider, modelOverride); + // Resolve environment and vault names from the assembled config so the compiled + // agent declares them. This lets syncAgentResources manage base resources + // through the plan/apply engine, giving them state tracking and drift detection. + // Invariant: buildRuntimeConfig produces exactly one environment (and at most one + // vault for providers that need credentials). If this assumption breaks, the agent + // would silently bind the wrong resource — fail fast instead. + const envKeys = Object.keys(baseConfig.environments ?? {}); + if (envKeys.length !== 1) { + throw new Error( + `Expected exactly 1 environment in runtime config, got ${envKeys.length}. ` + + `The agent compile path assumes a single base environment per provider.`, + ); + } + const environmentName = envKeys[0]!; + const vaultProfile = getVaultProfile(provider); + let vaultName: string | undefined; + if (vaultProfile) { + const vaultKeys = Object.keys(baseConfig.vaults ?? {}); + if (vaultKeys.length !== 1) { + throw new Error( + `Expected exactly 1 vault in runtime config for provider '${provider}', got ${vaultKeys.length}. ` + + `The agent compile path assumes a single base vault per provider.`, + ); + } + vaultName = vaultKeys[0]!; + } return { description: resolved.agent.description, model, instructions: resolved.agent.system, + environment: environmentName, + vault: vaultName, provider, builtinTools: resolved.agent.builtinTools, skills: resolved.agent.skills.map((skill) => diff --git a/apps/server/src/services/files/upload.ts b/apps/server/src/services/files/upload.ts index a217906..f00a968 100644 --- a/apps/server/src/services/files/upload.ts +++ b/apps/server/src/services/files/upload.ts @@ -47,8 +47,7 @@ export async function listUserFiles(): Promise { /** * Resolve a short-lived presigned download URL for a file (e.g. an agent-delivered artifact). - * Throws when the resolved provider has no download endpoint (bailian/claude); the webui's Mode A - * transport surfaces that as an error, while Mode B never calls this route. + * Throws when the resolved provider has no download endpoint (bailian/claude). */ export async function getUserFileDownloadUrl(id: string): Promise<{ url: string; expires_at?: string }> { return withAgentRuntime(DEFAULT_AGENT_ID, async (ctx, compiled) => { diff --git a/apps/server/src/services/sessions/playbook-session-adapter/index.ts b/apps/server/src/services/sessions/playbook-session-adapter/index.ts index 2b44098..8c52c2b 100644 --- a/apps/server/src/services/sessions/playbook-session-adapter/index.ts +++ b/apps/server/src/services/sessions/playbook-session-adapter/index.ts @@ -1,22 +1,15 @@ -import { - createPlaybookSessionRuntime, - getPlaybookAppId, - getSeedPlaybookAgentName, - PLAYBOOK_AGENT_NAME_PREFIX, - type RemotePlaybookAgent, -} from "@openagentpack/playbooks"; +import { getPlaybookAppId, getSeedPlaybookAgentName, PLAYBOOK_AGENT_NAME_PREFIX } from "@openagentpack/playbooks"; import { type CloudAgent, listCloudAgents, - type ProviderSessionEvent, readProjectRuntime, resolveSessionProvider, - type Session, startSessionRun, } from "@openagentpack/sdk"; import { loadAgentRuntimeInput, withAgentRuntime } from "@/services/runtime-factory"; import { agentMetadataOf } from "./dto"; import { ensureAgentApplied } from "./provision"; +import { createPlaybookSessionRuntime, type RemotePlaybookAgent } from "./runtime"; import { attachLiveStream, deletePlaybookSession, @@ -26,10 +19,10 @@ import { sendPlaybookSessionMessage, } from "./sessions"; -export type { ModeAPlaybookSessionDetail } from "./sessions"; +export type { PlaybookSessionDetail } from "./sessions"; -export function createModeAPlaybookSessionRuntime() { - return createPlaybookSessionRuntime({ +export function createServerPlaybookSessionRuntime() { + return createPlaybookSessionRuntime({ identity: { appId: getPlaybookAppId(), expectedAgentName: getSeedPlaybookAgentName, @@ -87,12 +80,10 @@ export function createModeAPlaybookSessionRuntime() { onDuplicateAgent({ playbookId, winner, duplicates }) { const all = [winner, ...duplicates]; console.warn( - `玩法「${playbookId}」匹配到 ${all.length} 个 active playbook Agent(${all + `\u73A9\u6CD5\u300C${playbookId}\u300D\u5339\u914D\u5230 ${all.length} \u4E2A active playbook Agent(${all .map((agent) => agent.id) - .join(", ")});取最近更新的 ${winner.id}。`, + .join(", ")})\uFF1B\u53D6\u6700\u8FD1\u66F4\u65B0\u7684 ${winner.id}\u3002`, ); }, }); } - -type ModeAPlaybookSessionDetail = import("./sessions").ModeAPlaybookSessionDetail; diff --git a/apps/server/src/services/sessions/playbook-session-adapter/provision.ts b/apps/server/src/services/sessions/playbook-session-adapter/provision.ts index 4a9c7e8..04129ec 100644 --- a/apps/server/src/services/sessions/playbook-session-adapter/provision.ts +++ b/apps/server/src/services/sessions/playbook-session-adapter/provision.ts @@ -1,4 +1,3 @@ -import type { RemotePlaybookAgent } from "@openagentpack/playbooks"; import type { CloudAgent } from "@openagentpack/sdk"; import { importResource, @@ -9,6 +8,7 @@ import { writeProjectRuntime, } from "@openagentpack/sdk"; import { loadAgentRuntimeInput } from "@/services/runtime-factory"; +import type { RemotePlaybookAgent } from "./runtime"; /** * Ensure a compiled catalog agent is provisioned to its provider (has a remote_id in state). diff --git a/packages/playbooks/tests/session-runtime.test.ts b/apps/server/src/services/sessions/playbook-session-adapter/runtime.test.ts similarity index 80% rename from packages/playbooks/tests/session-runtime.test.ts rename to apps/server/src/services/sessions/playbook-session-adapter/runtime.test.ts index 7d7db4e..19d9b68 100644 --- a/packages/playbooks/tests/session-runtime.test.ts +++ b/apps/server/src/services/sessions/playbook-session-adapter/runtime.test.ts @@ -1,21 +1,20 @@ import { describe, expect, test } from "bun:test"; +import { PLAYBOOK_APP_METADATA_KEY, PLAYBOOK_METADATA_KEY } from "@openagentpack/playbooks"; +import type { ProviderSessionEvent, Session } from "@openagentpack/sdk"; import { createPlaybookSessionRuntime, - PLAYBOOK_APP_METADATA_KEY, - PLAYBOOK_METADATA_KEY, PlaybookAgentIdentityMismatchError, pickPlaybookAgent, - playbookIdentityMismatchMessage, type RemotePlaybookAgent, - readinessFromPick, -} from "../src/index.ts"; +} from "./runtime"; +import type { PlaybookSessionDetail } from "./sessions"; const APP_ID = "agents-webui"; function agent(overrides: Partial = {}): RemotePlaybookAgent { return { id: "agent_1", - name: "Agents/设计师助手", + name: "Agents/\u8BBE\u8BA1\u5E08\u52A9\u624B", metadata: { [PLAYBOOK_APP_METADATA_KEY]: APP_ID, [PLAYBOOK_METADATA_KEY]: "designer", @@ -25,6 +24,22 @@ function agent(overrides: Partial = {}): RemotePlaybookAgen }; } +function fakeEvent(raw_type = "created"): ProviderSessionEvent { + return { type: "status", raw_type, raw: {} }; +} + +function fakeSession(id: string, agentId?: string): Session { + return { + session_id: id, + status: "running", + agent: agentId ? { agent_id: agentId } : undefined, + }; +} + +function fakeDetail(sessionId: string, agentId?: string): PlaybookSessionDetail { + return { session: fakeSession(sessionId, agentId), events: [] }; +} + describe("pickPlaybookAgent", () => { test("picks the current-app playbook stamp by newest update time", () => { const older = agent({ id: "agent_old", updatedAt: "2026-06-20T00:00:00.000Z" }); @@ -45,7 +60,7 @@ describe("pickPlaybookAgent", () => { metadata: { [PLAYBOOK_APP_METADATA_KEY]: APP_ID }, }), ], - { playbookId: "designer", appId: APP_ID, expectedAgentName: "Agents/设计师助手" }, + { playbookId: "designer", appId: APP_ID, expectedAgentName: "Agents/\u8BBE\u8BA1\u5E08\u52A9\u624B" }, ); expect(pick.agent).toBeUndefined(); @@ -62,51 +77,14 @@ describe("pickPlaybookAgent", () => { }); }); -describe("readinessFromPick", () => { - test("a matched agent is ready and carries its remote id", () => { - const pick = pickPlaybookAgent([agent({ id: "agent_ready" })], { playbookId: "designer", appId: APP_ID }); - - expect(readinessFromPick(pick, "designer")).toEqual({ - status: "ready", - playbookId: "designer", - remoteAgentId: "agent_ready", - }); - }); - - test("no candidate is missing/not_provisioned", () => { - const pick = pickPlaybookAgent([], { playbookId: "designer", appId: APP_ID }); - - expect(readinessFromPick(pick, "designer")).toEqual({ - status: "missing", - playbookId: "designer", - reason: "not_provisioned", - }); - }); - - test("a same-name unstamped agent is blocked with the shared mismatch message", () => { - const pick = pickPlaybookAgent([agent({ metadata: { [PLAYBOOK_APP_METADATA_KEY]: APP_ID } })], { - playbookId: "designer", - appId: APP_ID, - expectedAgentName: "Agents/设计师助手", - }); - - expect(readinessFromPick(pick, "designer")).toEqual({ - status: "blocked", - playbookId: "designer", - reason: "identity_mismatch", - message: playbookIdentityMismatchMessage("designer"), - }); - }); -}); - describe("createPlaybookSessionRuntime", () => { test("start ensures the selected agent, starts the provider session, attaches events, then returns detail", async () => { const calls: string[] = []; const liveEvents = (async function* () { - yield { type: "created" }; + yield fakeEvent(); })(); const runtime = createPlaybookSessionRuntime({ - identity: { appId: APP_ID, expectedAgentName: () => "Agents/设计师助手" }, + identity: { appId: APP_ID, expectedAgentName: () => "Agents/\u8BBE\u8BA1\u5E08\u52A9\u624B" }, agents: { async listPlaybookAgents() { calls.push("agents.list"); @@ -133,7 +111,7 @@ describe("createPlaybookSessionRuntime", () => { }, async getDetail(input) { calls.push(`sessions.detail:${input.sessionId}:${input.remoteAgentId}`); - return { sessionId: input.sessionId, agentId: input.remoteAgentId }; + return fakeDetail(input.sessionId, input.remoteAgentId); }, }, events: { @@ -153,7 +131,8 @@ describe("createPlaybookSessionRuntime", () => { model: "glm-5.1", }); - expect(detail).toEqual({ sessionId: "sess_1", agentId: "agent_existing" }); + expect(detail.session.session_id).toBe("sess_1"); + expect(detail.session.agent?.agent_id).toBe("agent_existing"); expect(calls).toEqual([ "agents.list", "agents.ensure:agent_existing:glm-5.1", @@ -165,7 +144,7 @@ describe("createPlaybookSessionRuntime", () => { test("start throws before ensuring when identity is blocked", async () => { const runtime = createPlaybookSessionRuntime({ - identity: { appId: APP_ID, expectedAgentName: () => "Agents/设计师助手" }, + identity: { appId: APP_ID, expectedAgentName: () => "Agents/\u8BBE\u8BA1\u5E08\u52A9\u624B" }, agents: { async listPlaybookAgents() { return [agent({ metadata: { [PLAYBOOK_APP_METADATA_KEY]: APP_ID } })]; @@ -201,7 +180,7 @@ describe("createPlaybookSessionRuntime", () => { test("send appends a message, attaches events, then returns detail", async () => { const calls: string[] = []; const liveEvents = (async function* () { - yield { type: "message" }; + yield fakeEvent("message"); })(); const runtime = createPlaybookSessionRuntime({ identity: { appId: APP_ID }, @@ -229,7 +208,7 @@ describe("createPlaybookSessionRuntime", () => { }, async getDetail(input) { calls.push(`sessions.detail:${input.sessionId}:${input.playbookId}`); - return { sessionId: input.sessionId, playbookId: input.playbookId }; + return fakeDetail(input.sessionId); }, }, events: { @@ -244,7 +223,7 @@ describe("createPlaybookSessionRuntime", () => { const detail = await runtime.send({ playbookId: "designer", sessionId: "sess_1", message: "continue" }); - expect(detail).toEqual({ sessionId: "sess_1", playbookId: "designer" }); + expect(detail.session.session_id).toBe("sess_1"); expect(calls).toEqual([ "sessions.send:sess_1:designer:continue", "events.attach:sess_1", @@ -267,7 +246,7 @@ describe("createPlaybookSessionRuntime", () => { sessions: { async list(input) { calls.push(`sessions.list:${input.playbookId}:${input.remoteAgentId}:${input.limit}:${input.pageToken}`); - return { sessions: [{ id: "sess_1" }], nextPageToken: "next" }; + return { sessions: [fakeSession("sess_1")], nextPageToken: "next" }; }, async start() { throw new Error("should not start"); @@ -291,7 +270,8 @@ describe("createPlaybookSessionRuntime", () => { pageToken: "page_1", }); - expect(listed).toEqual({ sessions: [{ id: "sess_1" }], nextPageToken: "next" }); + expect(listed.sessions[0]?.session_id).toBe("sess_1"); + expect(listed.nextPageToken).toBe("next"); expect(calls).toEqual(["sessions.list:designer:agent_1:10:page_1"]); }); @@ -322,14 +302,14 @@ describe("createPlaybookSessionRuntime", () => { }, async getDetail(input) { calls.push(`sessions.detail:${input.sessionId}:${input.playbookId}`); - return { sessionId: input.sessionId, playbookId: input.playbookId }; + return fakeDetail(input.sessionId); }, }, }); const detail = await runtime.getDetail({ playbookId: "designer", sessionId: "sess_1" }); - expect(detail).toEqual({ sessionId: "sess_1", playbookId: "designer" }); + expect(detail.session.session_id).toBe("sess_1"); expect(calls).toEqual(["sessions.detail:sess_1:designer"]); }); diff --git a/apps/server/src/services/sessions/playbook-session-adapter/runtime.ts b/apps/server/src/services/sessions/playbook-session-adapter/runtime.ts new file mode 100644 index 0000000..cc5796f --- /dev/null +++ b/apps/server/src/services/sessions/playbook-session-adapter/runtime.ts @@ -0,0 +1,288 @@ +/** + * Session runtime: agent-pick logic + orchestration for playbook sessions. + * + * Previously lived in `@openagentpack/playbooks/session-runtime`; inlined here + * because the server is the only production consumer. The adapter interface + * layer is replaced by a concrete dependency-injection object so orchestration + * remains unit-testable without cross-module mocking. + */ +import { PLAYBOOK_APP_METADATA_KEY, PLAYBOOK_METADATA_KEY } from "@openagentpack/playbooks"; +import type { ProviderSessionEvent, Session } from "@openagentpack/sdk"; +import type { PlaybookSessionDetail } from "./sessions"; + +// --------------------------------------------------------------------------- +// Input / output types (previously exported from @openagentpack/playbooks) +// --------------------------------------------------------------------------- + +export type MaybePromise = T | Promise; + +export type PlaybookSessionFile = { fileId: string; mountPath: string }; + +export type StartPlaybookSessionInput = { + playbookId: string; + prompt: string; + title?: string; + /** Cloud sandbox id; every session runs inside an environment. */ + environmentId: string; + vaultIds?: string[]; + files?: PlaybookSessionFile[]; + model?: string; +}; + +export type ProviderStartPlaybookSessionInput = StartPlaybookSessionInput & { + remoteAgentId: string; +}; + +export type StartedProviderPlaybookSession = { + sessionId: string; + events?: AsyncIterable; + completedEvents?: ProviderSessionEvent[]; +}; + +export type SendPlaybookSessionInput = { + playbookId: string; + sessionId: string; + message: string; +}; + +export type DeletePlaybookSessionInput = { + playbookId: string; + sessionId: string; +}; + +export type GetPlaybookSessionDetailInput = { + playbookId: string; + sessionId: string; +}; + +export type ListPlaybookSessionsInput = { + playbookId?: string; + remoteAgentId?: string; + limit?: number; + pageToken?: string; +}; + +export type PlaybookSessionList = { + sessions: Session[]; + nextPageToken?: string; +}; + +export type SentProviderPlaybookMessage = { + sessionId: string; + events?: AsyncIterable; + completedEvents?: ProviderSessionEvent[]; +}; + +// --------------------------------------------------------------------------- +// Remote agent types +// --------------------------------------------------------------------------- + +export type RemotePlaybookAgent = { + id: string; + name?: string; + metadata?: Record; + version?: string | number; + updatedAt?: string | number | null; + updated_at?: string | number | null; + archivedAt?: string | number | null; + archived_at?: string | number | null; +}; + +export type PlaybookAgentPick = { + agent: RemotePlaybookAgent | undefined; + duplicates: RemotePlaybookAgent[]; + identityMismatch: boolean; +}; + +// --------------------------------------------------------------------------- +// Identity-mismatch error +// --------------------------------------------------------------------------- + +export function playbookIdentityMismatchMessage(playbookId: string): string { + return `\u73A9\u6CD5\u300C${playbookId}\u300D\u5B58\u5728\u540C\u5E94\u7528\u540C\u540D Agent\uFF0C\u4F46 metadata.${PLAYBOOK_APP_METADATA_KEY}/${PLAYBOOK_METADATA_KEY} \u672A\u5BF9\u4E0A\uFF0C\u7591\u4F3C\u8EAB\u4EFD\u672A\u76D6\u7AE0\uFF1B\u8BF7\u68C0\u67E5\u914D\u7F6E\u800C\u975E\u91CD\u590D\u521B\u5EFA\u3002`; +} + +export class PlaybookAgentIdentityMismatchError extends Error { + constructor(playbookId: string) { + super(playbookIdentityMismatchMessage(playbookId)); + this.name = "PlaybookAgentIdentityMismatchError"; + } +} + +// --------------------------------------------------------------------------- +// pickPlaybookAgent — pure function, directly testable +// --------------------------------------------------------------------------- + +export function pickPlaybookAgent( + agents: RemotePlaybookAgent[], + input: { playbookId: string; appId: string; expectedAgentName?: string; includeArchived?: boolean }, +): PlaybookAgentPick { + const matched = agents.filter( + (agent) => + (input.includeArchived || !isArchived(agent)) && + agent.metadata?.[PLAYBOOK_APP_METADATA_KEY] === input.appId && + agent.metadata?.[PLAYBOOK_METADATA_KEY] === input.playbookId, + ); + if (matched.length <= 1) { + return { + agent: matched[0], + duplicates: [], + identityMismatch: matched.length === 0 && hasSameNameCurrentAppAgent(agents, input), + }; + } + const sorted = [...matched].sort((a, b) => epochOf(updatedAtOf(b)) - epochOf(updatedAtOf(a))); + const [agent, ...duplicates] = sorted; + return { agent, duplicates, identityMismatch: false }; +} + +// --------------------------------------------------------------------------- +// Dependency contract (concrete types, no generics) +// --------------------------------------------------------------------------- + +export type PlaybookSessionRuntimeDeps = { + identity: { + appId: string; + expectedAgentName?: (playbookId: string) => MaybePromise; + }; + agents: { + listPlaybookAgents(input: { playbookId: string; includeArchived: boolean }): Promise; + ensurePlaybookAgent(input: { + playbookId: string; + model?: string; + matched?: RemotePlaybookAgent; + }): Promise; + }; + sessions: { + list(input: ListPlaybookSessionsInput): Promise; + start(input: ProviderStartPlaybookSessionInput): Promise; + send(input: SendPlaybookSessionInput): Promise; + delete(input: DeletePlaybookSessionInput): Promise; + getDetail(input: { sessionId: string; playbookId: string; remoteAgentId?: string }): Promise; + }; + events?: { + attachLiveStream( + sessionId: string, + events: AsyncIterable, + seed?: ProviderSessionEvent[], + ): void; + seedCompleted(sessionId: string, events: ProviderSessionEvent[]): void; + }; + onDuplicateAgent?: (input: { + playbookId: string; + winner: RemotePlaybookAgent; + duplicates: RemotePlaybookAgent[]; + }) => void; +}; + +// --------------------------------------------------------------------------- +// Runtime interface +// --------------------------------------------------------------------------- + +export interface PlaybookSessionRuntime { + list(input: ListPlaybookSessionsInput): Promise; + getDetail(input: GetPlaybookSessionDetailInput): Promise; + start(input: StartPlaybookSessionInput): Promise; + send(input: SendPlaybookSessionInput): Promise; + delete(input: DeletePlaybookSessionInput): Promise; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createPlaybookSessionRuntime(deps: PlaybookSessionRuntimeDeps): PlaybookSessionRuntime { + async function findAgent(playbookId: string): Promise { + const [agents, expectedAgentName] = await Promise.all([ + deps.agents.listPlaybookAgents({ playbookId, includeArchived: false }), + deps.identity.expectedAgentName?.(playbookId), + ]); + return pickPlaybookAgent(agents, { + playbookId, + appId: deps.identity.appId, + expectedAgentName, + }); + } + + return { + async list(input) { + return deps.sessions.list(input); + }, + + async getDetail(input) { + return deps.sessions.getDetail(input); + }, + + async start(input) { + const pick = await findAgent(input.playbookId); + if (pick.identityMismatch) throw new PlaybookAgentIdentityMismatchError(input.playbookId); + if (pick.agent && pick.duplicates.length) { + deps.onDuplicateAgent?.({ playbookId: input.playbookId, winner: pick.agent, duplicates: pick.duplicates }); + } + const agent = await deps.agents.ensurePlaybookAgent({ + playbookId: input.playbookId, + model: input.model, + matched: pick.agent, + }); + const started = await deps.sessions.start({ ...input, remoteAgentId: agent.id }); + if (started.events) { + deps.events?.attachLiveStream(started.sessionId, started.events, started.completedEvents); + } else if (started.completedEvents?.length) { + deps.events?.seedCompleted(started.sessionId, started.completedEvents); + } + return deps.sessions.getDetail({ + sessionId: started.sessionId, + playbookId: input.playbookId, + remoteAgentId: agent.id, + }); + }, + + async send(input) { + const sent = await deps.sessions.send(input); + if (sent.events) { + deps.events?.attachLiveStream(sent.sessionId, sent.events, sent.completedEvents); + } else if (sent.completedEvents?.length) { + deps.events?.seedCompleted(sent.sessionId, sent.completedEvents); + } + return deps.sessions.getDetail({ + sessionId: sent.sessionId, + playbookId: input.playbookId, + }); + }, + + async delete(input) { + await deps.sessions.delete(input); + }, + }; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function hasSameNameCurrentAppAgent( + agents: RemotePlaybookAgent[], + input: { appId: string; expectedAgentName?: string }, +): boolean { + if (!input.expectedAgentName) return false; + return agents.some( + (agent) => + !isArchived(agent) && + agent.metadata?.[PLAYBOOK_APP_METADATA_KEY] === input.appId && + agent.name === input.expectedAgentName, + ); +} + +function isArchived(agent: RemotePlaybookAgent): boolean { + return agent.archivedAt != null || agent.archived_at != null; +} + +function updatedAtOf(agent: RemotePlaybookAgent): string | number | null | undefined { + return agent.updatedAt ?? agent.updated_at; +} + +function epochOf(value: string | number | null | undefined): number { + if (value == null) return 0; + if (typeof value === "number") return value; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} diff --git a/apps/server/src/services/sessions/playbook-session-adapter/sessions.ts b/apps/server/src/services/sessions/playbook-session-adapter/sessions.ts index d8121e8..1311add 100644 --- a/apps/server/src/services/sessions/playbook-session-adapter/sessions.ts +++ b/apps/server/src/services/sessions/playbook-session-adapter/sessions.ts @@ -1,4 +1,3 @@ -import type { DeletePlaybookSessionInput, SendPlaybookSessionInput } from "@openagentpack/playbooks"; import { deleteSession, getAgent, @@ -15,27 +14,21 @@ import { DEFAULT_AGENT_ID, getSessionAgent } from "@/services/agents/catalog"; import { withAgentRuntime } from "@/services/runtime-factory"; import { createEventBuffer, seedCompletedBuffer } from "@/services/sessions/event-buffer"; import { sortByUpdatedDesc, toSession } from "./dto"; +import type { DeletePlaybookSessionInput, ListPlaybookSessionsInput, SendPlaybookSessionInput } from "./runtime"; -export type ModeAPlaybookSessionDetail = { +export type PlaybookSessionDetail = { session: Session; events: ProviderSessionEvent[]; eventsNextPageToken?: string; }; -export type ModeASessionEventsPage = { +export type SessionEventsPage = { events: ProviderSessionEvent[]; eventsNextPageToken?: string; }; -type ModeAListPlaybookSessionsInput = { - playbookId?: string; - remoteAgentId?: string; - limit?: number; - pageToken?: string; -}; - export async function listPlaybookSessions( - input: ModeAListPlaybookSessionsInput, + input: ListPlaybookSessionsInput, ): Promise<{ sessions: Session[]; nextPageToken?: string }> { const limit = input.limit ?? 50; const page = input.pageToken?.trim() || undefined; @@ -77,7 +70,7 @@ async function listProviderSessionEventsPage( sessionId: string, provider: string, options: { pageToken?: string; limit?: number } = {}, -): Promise { +): Promise { const limit = options.limit ?? 100; const eventList = await listSessionEvents(ctx, sessionId, { provider, @@ -96,7 +89,7 @@ export async function listProviderSessionEvents( agentId = DEFAULT_AGENT_ID, pageToken?: string, limit?: number, -): Promise { +): Promise { const catalogAgentId = getSessionAgent(agentId) ? agentId : DEFAULT_AGENT_ID; return withAgentRuntime(catalogAgentId, async (ctx, compiled) => { const agent = getAgent(ctx, compiled.agentId); @@ -107,7 +100,7 @@ export async function listProviderSessionEvents( export async function readProviderSessionDetail( sessionId: string, agentId = DEFAULT_AGENT_ID, -): Promise { +): Promise { const catalogAgentId = getSessionAgent(agentId) ? agentId : DEFAULT_AGENT_ID; return withAgentRuntime(catalogAgentId, async (ctx, compiled) => { const agent = getAgent(ctx, compiled.agentId); diff --git a/apps/server/src/services/sessions/runner.ts b/apps/server/src/services/sessions/runner.ts index 1b9fe35..fa90a9d 100644 --- a/apps/server/src/services/sessions/runner.ts +++ b/apps/server/src/services/sessions/runner.ts @@ -16,8 +16,8 @@ import { import { DEFAULT_AGENT_ID, getSessionAgent } from "@/services/agents/catalog"; import { loadAgentRuntimeInput, withAgentRuntime } from "@/services/runtime-factory"; import { - createModeAPlaybookSessionRuntime, - type ModeAPlaybookSessionDetail, + createServerPlaybookSessionRuntime, + type PlaybookSessionDetail, } from "@/services/sessions/playbook-session-adapter"; import { listProviderSessionEvents } from "@/services/sessions/playbook-session-adapter/sessions"; @@ -43,9 +43,9 @@ export async function updatePlaybookAgentModel(slug: string, model: string): Pro import { createEventBuffer, seedCompletedBuffer } from "@/services/sessions/event-buffer"; /** A session plus its raw provider events (events are mapped to the contract at the route boundary). */ -export type SessionWithEvents = ModeAPlaybookSessionDetail; +export type SessionWithEvents = PlaybookSessionDetail; -const playbookSessionRuntime = createModeAPlaybookSessionRuntime(); +const playbookSessionRuntime = createServerPlaybookSessionRuntime(); export async function listSessionsForAgent(input: { agentId?: string; diff --git a/apps/server/src/services/skills/manage.ts b/apps/server/src/services/skills/manage.ts index 0e3e487..32cdf02 100644 --- a/apps/server/src/services/skills/manage.ts +++ b/apps/server/src/services/skills/manage.ts @@ -84,7 +84,7 @@ export async function deleteUserSkill(id: string): Promise { // Warm polling: the custom-skill security scan runs 3–5 min on the production workspace, so poll // gently (one provider call every SCAN_POLL_INTERVAL_MS) to keep load light — warming is off the // user's critical path, so cadence matters more than latency. Timeouts and cadence come from the -// shared scan-lifecycle module so they cannot drift from the SDK/Mode B copies. +// shared scan-lifecycle module so they cannot drift. /** * Pre-provision a seed-declared custom skill (name + downloadable url) ahead of first click: dedupe diff --git a/apps/server/tests/runtime-config.test.ts b/apps/server/tests/runtime-config.test.ts index 927711a..077c26f 100644 --- a/apps/server/tests/runtime-config.test.ts +++ b/apps/server/tests/runtime-config.test.ts @@ -12,9 +12,10 @@ describe("WebUI state scope", () => { expect(deriveWebUiStateScope()).toEqual({ projectId: RUNTIME_PROJECT_NAME }); }); - test("default state path preserves the historical on-disk location", () => { - const cwd = "/repo"; - expect(resolveStatePath({}, cwd)).toBe(resolve(cwd, "examples/bailian/bailian-cli/agents.state.json")); + test("default state path points to ~/.agents/playground.state.json", () => { + const { homedir } = require("node:os"); + const { join } = require("node:path"); + expect(resolveStatePath({}, "/repo")).toBe(join(homedir(), ".agents", "playground.state.json")); }); test("AGENTS_STATE_PATH overrides the default", () => { diff --git a/apps/server/tests/session-agents.test.ts b/apps/server/tests/session-agents.test.ts index 9a861e1..f3da19b 100644 --- a/apps/server/tests/session-agents.test.ts +++ b/apps/server/tests/session-agents.test.ts @@ -46,7 +46,7 @@ describe("session agent compiler", () => { expect(agent?.model).toBe("glm-5.1"); }); - test("stamps playbook metadata as the shared App identity (same key Mode B writes)", () => { + test("stamps playbook metadata as the shared App identity", () => { const compiled = compileAgentRuntime("base", baseConfig()); const agent = compiled.config.agents?.[compiled.agentId]; @@ -64,9 +64,12 @@ describe("session agent compiler", () => { const agent = compiled.config.agents?.[compiled.agentId]; expect(agent?.instructions).toContain("【角色设定】"); - // The agent declares no environment: the base sandbox is a standalone resource bound - // per-session via environment_id, not owned by the agent decl. - expect(agent?.environment).toBeUndefined(); + // The agent declares its environment and vault so syncAgentResources manages + // base resources through the plan/apply engine (state tracking + drift detection). + const environment = getEnvironmentProfile(); + const vault = getVaultProfile(); + expect(agent?.environment).toBe(environment.name); + expect(agent?.vault).toBe(vault?.name); }); test("emits official skills when playbook template declares them", () => { @@ -145,6 +148,7 @@ function baseConfig(provider = "bailian"): ResolvedProjectConfig { secret_value: "test-secret", ...(cred.networking ? { networking: cred.networking } : {}), })), + metadata: { "agents.vault": "true" }, }, } : {}, @@ -153,6 +157,7 @@ function baseConfig(provider = "bailian"): ResolvedProjectConfig { [environment.name]: { ...(environment.description ? { description: environment.description } : {}), config: environment.config, + metadata: { "agents.base": "true" }, }, } : {}, diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index a5732db..f063101 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"], - "types": ["node"], + "types": ["node", "bun"], "resolveJsonModule": true, "paths": { "@/*": ["./src/*"], diff --git a/apps/webui/src/lib/domain/resource-center/resources.ts b/apps/webui/src/lib/domain/resource-center/resources.ts index ce84383..2f8808f 100644 --- a/apps/webui/src/lib/domain/resource-center/resources.ts +++ b/apps/webui/src/lib/domain/resource-center/resources.ts @@ -44,8 +44,8 @@ export function deriveEnvironments(environments: CloudEnvironment[]): { rows: Re /** * Project-scoped vault rows: mirrors deriveEnvironments. This project provisions exactly one — - * the managed base vault (display_name Agents/secrets + agents.vault stamp, via findBaseVault) - * holding the DASHSCOPE_API_KEY (server-injected) — so foreign org vaults are excluded entirely. + * the managed base vault (agents.vault stamp, via findBaseVault) holding the DASHSCOPE_API_KEY + * (server-injected) — so foreign org vaults are excluded entirely. */ export function deriveVaults(vaults: CloudVault[]): { rows: ResourceVaultRow[]; baseVaultId?: string } { const base = findBaseVault(vaults); diff --git a/apps/webui/src/lib/domain/vault.ts b/apps/webui/src/lib/domain/vault.ts index 5cea465..4558885 100644 --- a/apps/webui/src/lib/domain/vault.ts +++ b/apps/webui/src/lib/domain/vault.ts @@ -4,8 +4,9 @@ import { formatApiErrorMessage } from "../api/error-message"; import { resolveStampedResource } from "../resolve-stamped"; // Identity of the shared base vault — the credential store holding the user-supplied -// DASHSCOPE_API_KEY. Mirrors the base-environment scheme (Agents/ name prefix + a metadata -// stamp): a vault has no `name` field, so identity rides on `display_name` + the stamp. +// DASHSCOPE_API_KEY. Identity relies on the metadata stamp alone (not display_name) because +// the engine and the imperative warm path may create vaults with different display_name values +// ("Cli Secrets" vs "Agents/secrets"), but both always set the `agents.vault` stamp. const BASE_VAULT_NAME = "Agents/secrets"; const BASE_VAULT_METADATA_KEY = "agents.vault"; const BASE_VAULT_METADATA_VALUE = "true"; @@ -14,15 +15,15 @@ const BASE_VAULT_METADATA: Record = { }; /** - * Two-layer lookup of the managed base vault (mirrors `findBaseEnvironment`): layer 1 nets - * by `display_name === "Agents/secrets"`; layer 2 re-confirms our stamp `agents.vault === "true"`. - * A display-name match WITHOUT the stamp is a foreign vault and is ignored. On duplicates, - * the most recently updated stamped vault wins. + * Identity lookup of the managed base vault via the metadata stamp + * `agents.vault === "true"`. The stamp is set both by the engine (plan/apply) + * and by the legacy imperative create path, so it is the universal identity + * signal regardless of how the vault was provisioned. On duplicates the most + * recently updated stamped vault wins. */ export function findBaseVault(vaults: CloudVault[]): CloudVault | undefined { return resolveStampedResource(vaults, { - matches: (vault) => - vault.display_name === BASE_VAULT_NAME && vault.metadata?.[BASE_VAULT_METADATA_KEY] === BASE_VAULT_METADATA_VALUE, + matches: (vault) => vault.metadata?.[BASE_VAULT_METADATA_KEY] === BASE_VAULT_METADATA_VALUE, updatedAt: (vault) => vault.updated_at, }).winner; } diff --git a/apps/webui/src/lib/playbooks/index.ts b/apps/webui/src/lib/playbooks/index.ts index 0f2d4eb..547526a 100644 --- a/apps/webui/src/lib/playbooks/index.ts +++ b/apps/webui/src/lib/playbooks/index.ts @@ -1,48 +1,34 @@ import { BASE_PLAYBOOK_ID, - createPlaybookSessionRuntime, DEFAULT_PLAYBOOK_PROVIDER, getDefaultPlaybook, getEnvironmentProfile, getPlaybook, getPlaybookAppId, getPlaybookDisplayName, - getSeedPlaybookAgentName, getVaultProfile, listPlaybookCards, listPlaybooks, PLAYBOOK_AGENT_NAME_PREFIX, PLAYBOOK_APP_METADATA_KEY, - type PlaybookReadiness, - pickPlaybookAgent, - playbookIdentityMismatchMessage, - type RemotePlaybookAgent, - readinessFromPick, resolveSeedPlaybookSkills, - type StartPlaybookSessionInput, } from "@openagentpack/playbooks"; import showcaseJson from "@/data/showcase.json"; import { resolveActivePlaybookProvider } from "@/lib/domain/config-api"; import type { ShowcaseCard } from "@/lib/showcase-types"; import type { RoleCard } from "./types"; -export type { PlaybookReadiness, RemotePlaybookAgent, StartPlaybookSessionInput }; export { BASE_PLAYBOOK_ID, - createPlaybookSessionRuntime, DEFAULT_PLAYBOOK_PROVIDER, getEnvironmentProfile, getPlaybook, getPlaybookAppId, getPlaybookDisplayName, - getSeedPlaybookAgentName, getVaultProfile, listPlaybooks, PLAYBOOK_AGENT_NAME_PREFIX, PLAYBOOK_APP_METADATA_KEY, - pickPlaybookAgent, - playbookIdentityMismatchMessage, - readinessFromPick, resolveSeedPlaybookSkills, }; diff --git a/apps/webui/tests/scan-lifecycle-parity.test.ts b/apps/webui/tests/scan-lifecycle-parity.test.ts index 5ee9a64..d5fb6fc 100644 --- a/apps/webui/tests/scan-lifecycle-parity.test.ts +++ b/apps/webui/tests/scan-lifecycle-parity.test.ts @@ -1,46 +1,25 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; -import { - classifyFileScan, - classifySkillScan, - SKILL_STATUS_CODE, - skillStatusFromCode, - skillStatusFromString, -} from "@openagentpack/sdk/scan-lifecycle"; +import { classifyFileScan, classifySkillScan, skillStatusFromString } from "@openagentpack/sdk/scan-lifecycle"; // ───────────────────────────────────────────────────────────────────────────── // Scan-lifecycle drift guard. The file/skill content-audit state machine — -// timeouts, poll cadence, status buckets, and the numeric↔string status mapping — -// used to live as hand-copied constants and if-chains across several surfaces -// (SDK adapter, server warming, the ResourceCenter UI), kept aligned only by -// "Kept in sync" comments. They now share one module (@openagentpack/sdk/scan-lifecycle). -// This test turns the comment contracts into assertions: (1) the numeric and -// string status mappings agree on terminal buckets — the `2 / unsafe → rejected` -// security invariant — and (2) no consumer re-declares the shared constants, so -// a future edit that forks one copy goes red. +// timeouts, poll cadence, and status buckets — used to live as hand-copied +// constants and if-chains across several surfaces (SDK adapter, server warming, +// the ResourceCenter UI), kept aligned only by "Kept in sync" comments. They now +// share one module (@openagentpack/sdk/scan-lifecycle). This test turns the +// comment contracts into assertions so a future edit that forks one copy goes red. // ───────────────────────────────────────────────────────────────────────────── -describe("numeric ↔ string skill status normalization isomorphism", () => { - test("every console numeric code maps to the same scan bucket as its neutral string", () => { - for (const [name, code] of Object.entries(SKILL_STATUS_CODE)) { - const fromCode = skillStatusFromCode(code); - expect(fromCode).toBe(name as ReturnType); - // Both transports must agree on the terminal bucket for the same logical status. - expect(classifySkillScan(fromCode)).toBe(classifySkillScan(skillStatusFromString(name))); - } - }); - - test("the security-scan failure terminal (2 / unsafe / rejected) all bucket as failed", () => { - expect(classifySkillScan(skillStatusFromCode(SKILL_STATUS_CODE.rejected))).toBe("failed"); +describe("string skill status normalization", () => { + test("the security-scan failure terminal (unsafe / rejected) buckets as failed", () => { expect(classifySkillScan(skillStatusFromString("unsafe"))).toBe("failed"); expect(classifySkillScan(skillStatusFromString("rejected"))).toBe("failed"); }); - test("unknown codes/strings keep polling rather than reading as terminal", () => { - expect(skillStatusFromCode(999)).toBe("checking"); - expect(skillStatusFromCode(undefined)).toBe("checking"); + test("unknown strings keep polling rather than reading as terminal", () => { expect(skillStatusFromString("security_scanning")).toBe("checking"); - expect(classifySkillScan(skillStatusFromCode(999))).toBe("pending"); + expect(classifySkillScan(skillStatusFromString("security_scanning"))).toBe("pending"); }); }); @@ -98,7 +77,7 @@ describe("single-source constants (no consumer re-declares them)", () => { // The raw literals that used to be physically copied. They must appear exactly // once — in the shared module — and nowhere in the consumers. - const FORKABLE = ["360_000", "120_000", "{ checking: 0"]; + const FORKABLE = ["360_000", "120_000"]; test("the shared module declares the constants", async () => { const src = await Bun.file(sharedModule).text(); diff --git a/bun.lock b/bun.lock index efe5542..98b07f2 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "apps/server": { "name": "@openagentpack/server", - "version": "0.0.2", + "version": "0.0.4", "dependencies": { "@hono/zod-openapi": "^1.4.0", "@openagentpack/playbooks": "workspace:*", @@ -63,7 +63,7 @@ }, "packages/cli": { "name": "@openagentpack/cli", - "version": "0.0.2", + "version": "0.1.1", "bin": { "agents": "dist/bin/agents.js", }, @@ -90,7 +90,7 @@ }, "packages/playground": { "name": "@openagentpack/playground", - "version": "0.0.2", + "version": "0.1.1", "bin": { "agents-playground": "dist/bin/playground.js", }, @@ -102,7 +102,6 @@ "zod": "^4.4.3", }, "devDependencies": { - "@openagentpack/playbooks": "workspace:*", "@openagentpack/server": "workspace:*", "@types/bun": "^1.3.14", "@types/node": "^25.9.3", @@ -112,7 +111,7 @@ }, "packages/sdk": { "name": "@openagentpack/sdk", - "version": "0.0.2", + "version": "0.1.1", "dependencies": { "jszip": "^3.10.1", "yaml": "^2.9.0", diff --git a/docs/contributing/maintainer-review.md b/docs/contributing/maintainer-review.md index 52b9f6c..47f860c 100644 --- a/docs/contributing/maintainer-review.md +++ b/docs/contributing/maintainer-review.md @@ -17,7 +17,7 @@ OpenAgentPack welcomes exploratory and AI-assisted contributions. The merge bar | CLI or SDK behavior | `Gate` | Contract plus a regression test or reproduction | | Provider contracts, config/schema, release/package files, workflows | `Gate` plus CodeQL | Behavior/risk and validation evidence; inspect compatibility and security impact | -The PR evidence check deliberately applies only to the third row. It does not require a design document, a linked Issue, or an AI-use declaration. +The PR evidence check deliberately applies only to the third row. It does not require a design document, a linked Issue, an AI-use declaration, a special commit-message marker, or a history rewrite. A generated `bun.lock` change by itself is covered by audit and compatibility checks rather than the evidence gate. ## GitHub settings diff --git a/packages/playbooks/package.json b/packages/playbooks/package.json index b80f591..7821126 100644 --- a/packages/playbooks/package.json +++ b/packages/playbooks/package.json @@ -3,7 +3,7 @@ "version": "0.0.1", "private": true, "license": "Apache-2.0", - "description": "OpenAgentPack shared playbook protocol, seed catalog, and runtime resolver", + "description": "OpenAgentPack shared playbook protocol, seed catalog, and resolvers", "type": "module", "exports": { ".": { diff --git a/packages/playbooks/src/index.ts b/packages/playbooks/src/index.ts index ae425fc..b3193c6 100644 --- a/packages/playbooks/src/index.ts +++ b/packages/playbooks/src/index.ts @@ -49,14 +49,6 @@ export { resolvePlaybookSkills, } from "./resolve.ts"; -export { - createPlaybookSessionRuntime, - pickPlaybookAgent, - PlaybookAgentIdentityMismatchError, - playbookIdentityMismatchMessage, - readinessFromPick, -} from "./session-runtime.ts"; - export type { AgentSkillResource, EnvironmentNetworking, @@ -78,24 +70,6 @@ export type { VaultProfile, } from "./types.ts"; -export type { - DeletePlaybookSessionInput, - PlaybookAgentAdapter, - PlaybookAgentPick, - PlaybookProviderSessionAdapter, - PlaybookReadiness, - PlaybookSessionEventsAdapter, - PlaybookSessionFile, - PlaybookSessionRuntime, - PlaybookSessionRuntimeAdapters, - ProviderStartPlaybookSessionInput, - RemotePlaybookAgent, - SendPlaybookSessionInput, - SentProviderPlaybookMessage, - StartedProviderPlaybookSession, - StartPlaybookSessionInput, -} from "./session-runtime.ts"; - const PLAYBOOK_SOURCES: Record = { bailian: bailianPlaybookJson as SourceAgent[], claude: claudePlaybookJson as SourceAgent[], diff --git a/packages/playbooks/src/resolve.ts b/packages/playbooks/src/resolve.ts index 8928bf1..6da1947 100644 --- a/packages/playbooks/src/resolve.ts +++ b/packages/playbooks/src/resolve.ts @@ -24,7 +24,7 @@ export function getPlaybookAgentName(_bundle: PlaybookBundle, playbookId: string * Resolve the model id to create the agent with. Playbook templates author a bailian-native model, * so the target provider's default is substituted unless the caller overrides it for this run; * falls back to the template model when the provider has no registered default. Single source of - * truth for both transports (Mode A catalog provisioning and Mode B console createAgent). + * truth for both the catalog provisioning and programmatic createAgent paths. */ export function resolvePlaybookModel(resolved: ResolvedPlaybook, provider: string, modelOverride?: string): string { return modelOverride ?? PROVIDER_DEFAULTS[provider]?.model ?? resolved.agent.model; diff --git a/packages/playbooks/src/session-runtime.ts b/packages/playbooks/src/session-runtime.ts deleted file mode 100644 index 4dfc559..0000000 --- a/packages/playbooks/src/session-runtime.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { PLAYBOOK_APP_METADATA_KEY, PLAYBOOK_METADATA_KEY } from "./metadata.ts"; - -export type MaybePromise = T | Promise; - -export type PlaybookSessionFile = { fileId: string; mountPath: string }; - -export type StartPlaybookSessionInput = { - playbookId: string; - prompt: string; - title?: string; - /** Cloud sandbox id; every session runs inside an environment. */ - environmentId: string; - vaultIds?: string[]; - files?: PlaybookSessionFile[]; - model?: string; -}; - -export type ProviderStartPlaybookSessionInput = StartPlaybookSessionInput & { - remoteAgentId: string; -}; - -export type StartedProviderPlaybookSession = { - sessionId: string; - events?: AsyncIterable; - completedEvents?: TEvent[]; -}; - -export type SendPlaybookSessionInput = { - playbookId: string; - sessionId: string; - message: string; -}; - -export type DeletePlaybookSessionInput = { - playbookId: string; - sessionId: string; -}; - -export type GetPlaybookSessionDetailInput = { - playbookId: string; - sessionId: string; -}; - -export type ListPlaybookSessionsInput = { - playbookId?: string; - remoteAgentId?: string; - limit?: number; - pageToken?: string; -}; - -export type PlaybookSessionList = { - sessions: TSession[]; - nextPageToken?: string; -}; - -export type SentProviderPlaybookMessage = { - sessionId: string; - events?: AsyncIterable; - completedEvents?: TEvent[]; -}; - -export type RemotePlaybookAgent = { - id: string; - name?: string; - metadata?: Record; - version?: string | number; - updatedAt?: string | number | null; - updated_at?: string | number | null; - archivedAt?: string | number | null; - archived_at?: string | number | null; -}; - -export type PlaybookAgentPick = { - agent: TAgent | undefined; - duplicates: TAgent[]; - identityMismatch: boolean; -}; - -export type PlaybookReadiness = - | { status: "ready"; playbookId: string; remoteAgentId: string } - | { status: "missing"; playbookId: string; reason: "not_provisioned" } - | { status: "unknown"; playbookId: string; reason: "not_checked" | "check_failed" } - | { status: "blocked"; playbookId: string; reason: "identity_mismatch"; message: string }; - -export interface PlaybookAgentAdapter { - listPlaybookAgents(input: { playbookId: string; includeArchived: boolean }): Promise; - ensurePlaybookAgent(input: { playbookId: string; model?: string; matched?: TAgent }): Promise; -} - -export interface PlaybookProviderSessionAdapter { - list(input: ListPlaybookSessionsInput): Promise>; - start(input: ProviderStartPlaybookSessionInput): Promise>; - send(input: SendPlaybookSessionInput): Promise>; - delete(input: DeletePlaybookSessionInput): Promise; - getDetail(input: { sessionId: string; playbookId: string; remoteAgentId?: string }): Promise; -} - -export interface PlaybookSessionEventsAdapter { - attachLiveStream(sessionId: string, events: AsyncIterable, seed?: TEvent[]): void; - seedCompleted(sessionId: string, events: TEvent[]): void; -} - -export interface PlaybookSessionRuntime { - list(input: ListPlaybookSessionsInput): Promise>; - getDetail(input: GetPlaybookSessionDetailInput): Promise; - start(input: StartPlaybookSessionInput): Promise; - send(input: SendPlaybookSessionInput): Promise; - delete(input: DeletePlaybookSessionInput): Promise; -} - -export type PlaybookSessionRuntimeAdapters< - TDetail, - TEvent = unknown, - TSession = unknown, - TAgent extends RemotePlaybookAgent = RemotePlaybookAgent, -> = { - identity: { - appId: string; - expectedAgentName?: (playbookId: string) => MaybePromise; - }; - agents: PlaybookAgentAdapter; - sessions: PlaybookProviderSessionAdapter; - events?: PlaybookSessionEventsAdapter; - onDuplicateAgent?: (input: { playbookId: string; winner: TAgent; duplicates: TAgent[] }) => void; -}; - -// Single source of the identity-mismatch copy, shared by the runtime error, the -// readiness mapping, and Mode B's resolver throw. -export function playbookIdentityMismatchMessage(playbookId: string): string { - return `玩法「${playbookId}」存在同应用同名 Agent,但 metadata.${PLAYBOOK_APP_METADATA_KEY}/${PLAYBOOK_METADATA_KEY} 未对上,疑似身份未盖章;请检查配置而非重复创建。`; -} - -export class PlaybookAgentIdentityMismatchError extends Error { - constructor(playbookId: string) { - super(playbookIdentityMismatchMessage(playbookId)); - this.name = "PlaybookAgentIdentityMismatchError"; - } -} - -export function pickPlaybookAgent( - agents: TAgent[], - input: { playbookId: string; appId: string; expectedAgentName?: string; includeArchived?: boolean }, -): PlaybookAgentPick { - const matched = agents.filter( - (agent) => - (input.includeArchived || !isArchived(agent)) && - agent.metadata?.[PLAYBOOK_APP_METADATA_KEY] === input.appId && - agent.metadata?.[PLAYBOOK_METADATA_KEY] === input.playbookId, - ); - if (matched.length <= 1) { - return { - agent: matched[0], - duplicates: [], - identityMismatch: matched.length === 0 && hasSameNameCurrentAppAgent(agents, input), - }; - } - const sorted = [...matched].sort((a, b) => epochOf(updatedAtOf(b)) - epochOf(updatedAtOf(a))); - const [agent, ...duplicates] = sorted; - return { agent, duplicates, identityMismatch: false }; -} - -// The single source of the pick→PlaybookReadiness mapping, shared by the per-id runtime -// path and Mode B's first-screen batch prime, so identity-drift handling lives in one place. -export function readinessFromPick( - pick: PlaybookAgentPick, - playbookId: string, -): PlaybookReadiness { - if (pick.identityMismatch) { - return { - status: "blocked", - playbookId, - reason: "identity_mismatch", - message: playbookIdentityMismatchMessage(playbookId), - }; - } - if (!pick.agent) return { status: "missing", playbookId, reason: "not_provisioned" }; - return { status: "ready", playbookId, remoteAgentId: pick.agent.id }; -} - -export function createPlaybookSessionRuntime< - TDetail, - TEvent = unknown, - TSession = unknown, - TAgent extends RemotePlaybookAgent = RemotePlaybookAgent, ->( - adapters: PlaybookSessionRuntimeAdapters, -): PlaybookSessionRuntime { - async function findAgent(playbookId: string): Promise> { - const [agents, expectedAgentName] = await Promise.all([ - adapters.agents.listPlaybookAgents({ playbookId, includeArchived: false }), - adapters.identity.expectedAgentName?.(playbookId), - ]); - return pickPlaybookAgent(agents, { - playbookId, - appId: adapters.identity.appId, - expectedAgentName, - }); - } - - return { - async list(input) { - return adapters.sessions.list(input); - }, - - async getDetail(input) { - return adapters.sessions.getDetail(input); - }, - - async start(input) { - const pick = await findAgent(input.playbookId); - if (pick.identityMismatch) throw new PlaybookAgentIdentityMismatchError(input.playbookId); - if (pick.agent && pick.duplicates.length) { - adapters.onDuplicateAgent?.({ playbookId: input.playbookId, winner: pick.agent, duplicates: pick.duplicates }); - } - const agent = await adapters.agents.ensurePlaybookAgent({ - playbookId: input.playbookId, - model: input.model, - matched: pick.agent, - }); - const started = await adapters.sessions.start({ ...input, remoteAgentId: agent.id }); - if (started.events) { - adapters.events?.attachLiveStream(started.sessionId, started.events, started.completedEvents); - } else if (started.completedEvents?.length) { - adapters.events?.seedCompleted(started.sessionId, started.completedEvents); - } - return adapters.sessions.getDetail({ - sessionId: started.sessionId, - playbookId: input.playbookId, - remoteAgentId: agent.id, - }); - }, - - async send(input) { - const sent = await adapters.sessions.send(input); - if (sent.events) { - adapters.events?.attachLiveStream(sent.sessionId, sent.events, sent.completedEvents); - } else if (sent.completedEvents?.length) { - adapters.events?.seedCompleted(sent.sessionId, sent.completedEvents); - } - return adapters.sessions.getDetail({ - sessionId: sent.sessionId, - playbookId: input.playbookId, - }); - }, - - async delete(input) { - await adapters.sessions.delete(input); - }, - }; -} - -function hasSameNameCurrentAppAgent( - agents: TAgent[], - input: { appId: string; expectedAgentName?: string }, -): boolean { - if (!input.expectedAgentName) return false; - return agents.some( - (agent) => - !isArchived(agent) && - agent.metadata?.[PLAYBOOK_APP_METADATA_KEY] === input.appId && - agent.name === input.expectedAgentName, - ); -} - -function isArchived(agent: RemotePlaybookAgent): boolean { - return agent.archivedAt != null || agent.archived_at != null; -} - -function updatedAtOf(agent: RemotePlaybookAgent): string | number | null | undefined { - return agent.updatedAt ?? agent.updated_at; -} - -function epochOf(value: string | number | null | undefined): number { - if (value == null) return 0; - if (typeof value === "number") return value; - const parsed = Date.parse(value); - return Number.isNaN(parsed) ? 0 : parsed; -} diff --git a/packages/playground/package.json b/packages/playground/package.json index 8223588..99c94c6 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -48,7 +48,6 @@ }, "devDependencies": { "@types/bun": "^1.3.14", - "@openagentpack/playbooks": "workspace:*", "@openagentpack/server": "workspace:*", "@types/node": "^25.9.3", "tsup": "^8.5.1", diff --git a/packages/sdk/src/internal/core/agent-builder.ts b/packages/sdk/src/internal/core/agent-builder.ts index f854dc6..c64ddac 100644 --- a/packages/sdk/src/internal/core/agent-builder.ts +++ b/packages/sdk/src/internal/core/agent-builder.ts @@ -24,6 +24,7 @@ export interface AgentBuildInput { model?: AgentDecl["model"]; instructions?: string; environment?: string; + vault?: string; provider?: string; builtinTools?: string[]; skills?: AgentSkillBuildInput[]; @@ -82,6 +83,7 @@ export function buildAgentDecl(base: AgentDecl | undefined, input: AgentBuildInp model, instructions: input.instructions ?? base?.instructions ?? "", ...(input.environment ? { environment: input.environment } : {}), + ...(input.vault ? { vault: input.vault } : {}), provider: input.provider ?? base?.provider, tools: { ...(base?.tools ?? { builtin: [] }), diff --git a/packages/sdk/src/internal/core/session-event-sanitizer.ts b/packages/sdk/src/internal/core/session-event-sanitizer.ts index 4c46c9f..d21851f 100644 --- a/packages/sdk/src/internal/core/session-event-sanitizer.ts +++ b/packages/sdk/src/internal/core/session-event-sanitizer.ts @@ -1,6 +1,6 @@ // Pure ProviderSessionEvent → contract SessionEvent sanitizer. Type-only imports keep this // module free of any Node/runtime dependency, so it is safe to bundle into a browser via the -// `@openagentpack/sdk/session-events` subpath (consumed by the webui Mode B console-direct transport). +// `@openagentpack/sdk/session-events` subpath. import { redactSensitiveText } from "../../redaction.ts"; import type { SessionContentBlock, SessionEvent } from "../types/dto.ts"; @@ -24,8 +24,8 @@ export function sanitizeSessionEvent( // contract event, preserving the original Agents `type` via `raw_type` so no detail // is dropped. Transport concerns added here: secret redaction + truncation, // surfaced as `metadata.redacted` / `metadata.truncated` flags (the contract event - // has no dedicated fields for them, and Mode B trusts the base, so they live in the - // open metadata bag where the presentation layer can read them uniformly). + // has no dedicated fields for them, so they live in the open metadata bag where the + // presentation layer can read them uniformly). const isToolEvent = event.type === "tool_result" || event.type === "tool_use"; const textLimit = isToolEvent ? TOOL_TEXT_LIMIT : DEFAULT_TEXT_LIMIT; const primarySource = event.content ?? (event.type === "tool_use" ? event.tool_input : undefined); diff --git a/packages/sdk/src/internal/providers/bailian/event-mapper.ts b/packages/sdk/src/internal/providers/bailian/event-mapper.ts index 3637561..fcdad88 100644 --- a/packages/sdk/src/internal/providers/bailian/event-mapper.ts +++ b/packages/sdk/src/internal/providers/bailian/event-mapper.ts @@ -1,6 +1,6 @@ // Pure Bailian raw-event → ProviderSessionEvent mapper. Type-only imports keep this module free // of any Node/runtime dependency, so it is safe to bundle into a browser via the -// `@openagentpack/sdk/session-events` subpath (consumed by the webui console-direct transport). +// `@openagentpack/sdk/session-events` subpath. import type { SessionEventType } from "../../types/dto.ts"; import type { ProviderSessionEvent } from "../../types/session-event.ts"; @@ -57,12 +57,12 @@ export function toSessionEvent(raw: Record): ProviderSessionEve event.content = (raw.message as string) ?? extractContentText(raw); } - // TODO(artifact-download, Mode B): bailian's `download_file` builtin flows through + // TODO(artifact-download): bailian's `download_file` builtin flows through // tool_call/tool_call_output above — there is NO structured artifact-delivered event in the // current wire, and we have no real bailian delivery sample to confirm one. When such a sample // exists, populate `event.artifact = { file_id, filename, content_type, size }` here (mirroring // qoder/mapper.ts) so the shared sanitizer surfaces it as metadata.artifact and the webui shows - // the same download card. Until then Mode B simply produces no artifact (graceful: no card). + // the same download card. return event; } diff --git a/packages/sdk/src/internal/types/dto.ts b/packages/sdk/src/internal/types/dto.ts index 4eb08ac..410e93a 100644 --- a/packages/sdk/src/internal/types/dto.ts +++ b/packages/sdk/src/internal/types/dto.ts @@ -112,8 +112,7 @@ export type AgentRecoveryAction = z.infer; // A raw cloud agent as listed from the provider (Bailian `/agents` list) — the actual // remote object, NOT a local config agent. This is the resource-center's source of truth: // it surfaces same-name duplicates and identity-stamp drift (metadata.playbook vs agents.*) that -// the local config view cannot. Wire fields are snake_case across both transports; Mode B -// (console RPC) normalizes its camelCase into this shape. tools/skills/mcp_servers stay +// the local config view cannot. Wire fields are snake_case. tools/skills/mcp_servers stay // loose (`unknown`) because shapes differ by provider and the resource center only inspects // metadata/name/timestamps for classification. export const CloudAgentSchema = z.object({ @@ -142,9 +141,8 @@ export type ListCloudAgentsResponse = z.infer; // ────────────────────────────────────────────────────────────────────────── -// Session-runtime contract — single source of truth. Wire fields are snake_case -// across every transport (Mode A REST and Mode B console RPC), so neither side -// remaps casing or renames fields. See openspec/specs/api-contract and -// openspec/specs/webui-console-direct. +// Session-runtime contract — single source of truth. Wire fields are snake_case. // ────────────────────────────────────────────────────────────────────────── // The Agents session-event `type` values. The contract keeps the full set for diff --git a/packages/sdk/src/redaction.ts b/packages/sdk/src/redaction.ts index ddff0e2..6c83611 100644 --- a/packages/sdk/src/redaction.ts +++ b/packages/sdk/src/redaction.ts @@ -1,5 +1,5 @@ // Pure secret-redaction helpers shared across transports (server-side sanitizer -// and the browser-side console-direct normalizer). Regex/URL only — no Node or zod deps, +// and browser-side normalizers). Regex/URL only — no Node or zod deps, // so this module is safe to import into a browser bundle via `@openagentpack/sdk/redaction`. const REDACTED = "[redacted]"; diff --git a/packages/sdk/src/scan-lifecycle.ts b/packages/sdk/src/scan-lifecycle.ts index 08bc950..38e67ac 100644 --- a/packages/sdk/src/scan-lifecycle.ts +++ b/packages/sdk/src/scan-lifecycle.ts @@ -13,7 +13,7 @@ export type ScanPhase = "pending" | "ready" | "failed"; // clear 5 min with margin. File audit clears faster (~15s–2min observed). export const SCAN_SKILL_TIMEOUT_MS = 360_000; export const SCAN_FILE_TIMEOUT_MS = 120_000; -// Warming / console poll cadence: one provider call every 8s keeps load light. Warming is off the +// Warm poll cadence: one provider call every 8s keeps load light. Warming is off the // user's critical path, so cadence matters more than latency. export const SCAN_POLL_INTERVAL_MS = 8000; @@ -26,25 +26,9 @@ export interface ScanBackoff { export const SKILL_SCAN_BACKOFF: ScanBackoff = { initial: 2000, factor: 2, max: 8000 }; export const FILE_SCAN_BACKOFF: ScanBackoff = { initial: 1000, factor: 1.5, max: 4000 }; -// Numeric ISkill(Version).status (console/provider internal) → neutral string. The wire enum is -// 0 checking / 1 active / 2 rejected / 100 deleted. -export const SKILL_STATUS_CODE = { checking: 0, active: 1, rejected: 2, deleted: 100 } as const; -const CODE_TO_SKILL_STATUS: Record = { - 0: "checking", - 1: "active", - 2: "rejected", - 100: "deleted", -}; - -/** Numeric console skill status → neutral SkillScanStatus (unknown codes read as still-checking). */ -export function skillStatusFromCode(code: number | undefined): SkillScanStatus { - return (code != null ? CODE_TO_SKILL_STATUS[code] : undefined) ?? "checking"; -} - // OpenAPI skill status string → neutral SkillScanStatus. Crucially `unsafe` is the security-scan // FAILURE terminal and must map to `rejected`, NOT `checking` — otherwise a failed skill displays -// as still-scanning forever. (Numeric 2 maps to the same `rejected` bucket via skillStatusFromCode, -// keeping both transports' status display in sync — now by shared code, not a comment contract.) +// as still-scanning forever. export function skillStatusFromString(raw: unknown): SkillScanStatus { switch (String(raw ?? "").toLowerCase()) { case "active": @@ -92,8 +76,7 @@ export interface PollUntilOptions { /** * Generic content-scan poll loop: poll → resolve on "ready", throw onFailed on "failed", throw - * onTimeout once the deadline passes, otherwise sleep and retry. Supersedes the four hand-rolled - * copies (SDK file/skill waits, server warm loops, Mode B waitForActive). + * onTimeout once the deadline passes, otherwise sleep and retry. */ export async function pollUntil(opts: PollUntilOptions): Promise { const start = Date.now(); diff --git a/packages/sdk/src/session-events.ts b/packages/sdk/src/session-events.ts index e4bea1d..7c77673 100644 --- a/packages/sdk/src/session-events.ts +++ b/packages/sdk/src/session-events.ts @@ -1,7 +1,7 @@ // Pure, browser-safe subpath barrel for the shared session-event mapping pipeline. -// Re-exports only type-only-importing modules so the webui (Mode B console-direct) -// can compose the same raw→ProviderSessionEvent→contract-SessionEvent pipeline that -// the server (Mode A) uses, WITHOUT pulling in the Node-only main SDK bundle. +// Re-exports only type-only-importing modules so browser consumers can compose the +// same raw→ProviderSessionEvent→contract-SessionEvent pipeline that the server uses, +// WITHOUT pulling in the Node-only main SDK bundle. export { sanitizeSessionEvent, sanitizeSessionEvents } from "./internal/core/session-event-sanitizer.ts"; export { toSessionEvent as toBailianSessionEvent } from "./internal/providers/bailian/event-mapper.ts"; diff --git a/packages/sdk/tests/e2e/bailian-adapter.test.ts b/packages/sdk/tests/e2e/bailian-adapter.test.ts index 9c35dd4..98f20d2 100644 --- a/packages/sdk/tests/e2e/bailian-adapter.test.ts +++ b/packages/sdk/tests/e2e/bailian-adapter.test.ts @@ -584,8 +584,7 @@ describe("BailianAdapter e2e", () => { // The OpenAPI skill status enum is security_scanning/active/unsafe/deleted (internal // 0/1/2/100), verified against the live provider API. `unsafe` is the scan-failure terminal and must - // surface as the neutral `rejected` — not `checking` — so it matches Mode B (internal 2 → - // rejected) and the UI shows 已拒绝 instead of a stuck 扫描中. + // surface as the neutral `rejected` — not `checking` — so the UI shows 已拒绝 instead of a stuck 扫描中. test("listSkills maps OpenAPI status enum to neutral status", async () => { const { restore } = mockFetch([ { diff --git a/scripts/pr-evidence.test.ts b/scripts/pr-evidence.test.ts index 48c654d..aa4920e 100644 --- a/scripts/pr-evidence.test.ts +++ b/scripts/pr-evidence.test.ts @@ -3,10 +3,21 @@ import { requiresMaintainerEvidence, validateMaintainerEvidence } from "./pr-evi describe("pull-request maintainer evidence", () => { test("does not add a merge barrier for ordinary contribution paths", () => { - expect(requiresMaintainerEvidence(["README.md", "packages/cli/src/program.ts"])).toBe(false); + expect(requiresMaintainerEvidence(["README.md", "packages/cli/src/program.ts", "bun.lock"])).toBe(false); expect(validateMaintainerEvidence("", ["README.md"])).toBeUndefined(); }); + test("uses PR evidence instead of commit-message markers for control-plane changes", () => { + const files = [".github/workflows/ci.yml", "scripts/verify.ts"]; + expect(requiresMaintainerEvidence(files)).toBe(true); + expect(validateMaintainerEvidence("## Summary\nAdjust CI", files)).toContain("## Behavior / risk"); + }); + + test("treats package manifests, but not the generated lockfile alone, as high risk", () => { + expect(requiresMaintainerEvidence(["packages/sdk/package.json"])).toBe(true); + expect(requiresMaintainerEvidence(["bun.lock"])).toBe(false); + }); + test("asks for behavior and validation evidence on high-risk paths", () => { const files = ["packages/sdk/src/internal/providers/interface.ts"]; expect(requiresMaintainerEvidence(files)).toBe(true); diff --git a/scripts/pr-evidence.ts b/scripts/pr-evidence.ts index a675638..25de7e9 100644 --- a/scripts/pr-evidence.ts +++ b/scripts/pr-evidence.ts @@ -1,9 +1,18 @@ import { existsSync } from "node:fs"; const highRiskPathPatterns = [ + // Verification and automation control plane. These changes require explicit + // PR evidence, but never a special commit message or history rewrite. + /^\.dependency-cruiser\.cjs$/, + /^sgconfig\.yml$/, + /^biome\.json$/, + /^tools\/architecture\//, + /^\.githooks\//, + /^\.claude\/hooks\//, /^\.github\/workflows\//, + /^scripts\/(?:lint-changed\.sh|verify(?:\.test)?\.ts|pr-evidence(?:\.test)?\.ts|setup-githooks\.sh)$/, /^scripts\/release\//, - /^(?:package\.json|bun\.lock)$/, + /(?:^|\/)package\.json$/, /^packages\/sdk\/src\/internal\/(?:parser\/|types\/(?:config|session)\.ts|providers\/(?:interface|capabilities|registry|base-client)\.ts)/, ];