Skip to content

Commit c1bbcb1

Browse files
committed
fix(bailian): harden native deployment lifecycle
1 parent 0e7cfba commit c1bbcb1

19 files changed

Lines changed: 915 additions & 68 deletions

examples/bailian/deployment/agents.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,4 @@ deployments:
5555
# Local file uploaded at `apply` time and mounted into each run's Session.
5656
- type: file
5757
source: ./data/report-template.md
58-
mount_path: /data/report-template.md
58+
mount_path: /mnt/report-template.md

packages/sdk/src/internal/core/validate-config.ts

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { getProvider } from "../providers/registry.ts";
99
import type { ProjectConfig } from "../types/config.ts";
1010
import type { Diagnostic } from "../types/plan.ts";
1111
import type { ResourceAddress } from "../types/state.ts";
12-
import { providerMountPrefix } from "../utils/sandbox-mount.ts";
12+
import { providerMountPrefix, resolveSandboxMountPath } from "../utils/sandbox-mount.ts";
1313
import { findMissingBailianMcpToolConfigs } from "../validation/bailian.ts";
1414

1515
export interface ValidateProjectConfigOptions {
@@ -370,7 +370,11 @@ export function collectProviderCapabilities(
370370
// resources accept files only. Surface what gets dropped.
371371
for (const [name, deployment] of Object.entries(config.deployments ?? {})) {
372372
if (deployment.provider && deployment.provider !== providerName) continue;
373-
const addr: ResourceAddress = { type: "deployment", name, provider: providerName };
373+
const addr: ResourceAddress = {
374+
type: "deployment",
375+
name,
376+
provider: providerName,
377+
};
374378

375379
if (deployment.initial_events?.some((event) => event.type === "user.define_outcome")) {
376380
diagnostics.warning(
@@ -379,6 +383,15 @@ export function collectProviderCapabilities(
379383
addr,
380384
);
381385
}
386+
if (
387+
!deployment.initial_events?.some((event) => event.type === "user.message" || event.type === "system.message")
388+
) {
389+
diagnostics.error(
390+
`${providerName}.deployment.initial_events.message_required`,
391+
`deployment.${name}: Bailian requires at least one user.message or system.message initial event; user.define_outcome events are dropped.`,
392+
addr,
393+
);
394+
}
382395

383396
if (deployment.resources?.some((resource) => resource.type === "github_repository")) {
384397
diagnostics.warning(
@@ -387,6 +400,44 @@ export function collectProviderCapabilities(
387400
addr,
388401
);
389402
}
403+
404+
const mountPrefix = providerMountPrefix(providerName);
405+
const normalizedMountPaths = new Set<string>();
406+
for (const resource of deployment.resources ?? []) {
407+
if (resource.type !== "file") continue;
408+
if (!resource.mount_path?.trim()) {
409+
diagnostics.error(
410+
`${providerName}.deployment.file.mount_path.required`,
411+
`deployment.${name}: Bailian file resources require mount_path.`,
412+
addr,
413+
);
414+
continue;
415+
}
416+
if (
417+
mountPrefix &&
418+
resource.mount_path.startsWith("/") &&
419+
resource.mount_path !== mountPrefix &&
420+
!resource.mount_path.startsWith(`${mountPrefix}/`)
421+
) {
422+
diagnostics.error(
423+
`${providerName}.deployment.file.mount_path.invalid`,
424+
`deployment.${name}: Bailian file mount_path must start with '${mountPrefix}/'.`,
425+
addr,
426+
);
427+
continue;
428+
}
429+
430+
const normalizedMountPath = resolveSandboxMountPath(providerName, resource.mount_path);
431+
if (normalizedMountPaths.has(normalizedMountPath)) {
432+
diagnostics.error(
433+
`${providerName}.deployment.file.mount_path.duplicate`,
434+
`deployment.${name}: Bailian file mount_path '${normalizedMountPath}' is duplicated after normalization.`,
435+
addr,
436+
);
437+
} else {
438+
normalizedMountPaths.add(normalizedMountPath);
439+
}
440+
}
390441
}
391442
}
392443

@@ -477,7 +528,11 @@ export function collectProviderCapabilities(
477528
if (config.deployments && caps.deployment.tier === "emulated") {
478529
for (const [name, dep] of Object.entries(config.deployments)) {
479530
if (dep.provider && dep.provider !== providerName) continue;
480-
const addr: ResourceAddress = { type: "deployment", name, provider: providerName };
531+
const addr: ResourceAddress = {
532+
type: "deployment",
533+
name,
534+
provider: providerName,
535+
};
481536

482537
if (dep.schedule) {
483538
diagnostics.warning(

packages/sdk/src/internal/executor/executor.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getResourceDeclaration } from "../planner/declaration.ts";
55
import { computeReplacementFingerprint, computeResourceHash } from "../planner/hasher.ts";
66
import { buildReadinessBaseline } from "../planner/plan-semantics.ts";
77
import { ApiError, ConflictError } from "../providers/base-client.ts";
8+
import { DeploymentCreateConflictError } from "../providers/deployment-conflict.ts";
89
import { readComparableIfSupported } from "../providers/drift-support.ts";
910
import type { RemoteResource } from "../providers/interface.ts";
1011
import type { DriftReadAdapter, ResourceCrudAdapter } from "../providers/resource-workflow.ts";
@@ -597,16 +598,41 @@ async function executeActionInner(
597598
case "deployment": {
598599
const decl = ctx.config.deployments![name]!;
599600
const refs = resolveDeploymentRefs(name, ctx.config, address.provider, ctx.state);
600-
if (isUpdate) {
601-
result = await provider.updateDeployment(existingId!, name, decl, refs, ctx.configPath ?? "");
602-
} else {
601+
const hasLocalFileSources = decl.resources?.some(
602+
(resource) => resource.type === "file" && !resource.file_id && Boolean(resource.source),
603+
);
604+
const materializeDeployment = async (): Promise<RemoteResource> => {
603605
try {
604-
result = await provider.createDeployment(name, decl, refs, ctx.configPath ?? "");
606+
return await provider.createDeployment(name, decl, refs, ctx.configPath ?? "");
605607
} catch (err) {
606-
result = await adoptOnConflict(err, address, provider, ctx.onFeedback, {
607-
onExisting: (existing) => provider.updateDeployment(existing.id!, name, decl, refs, ctx.configPath ?? ""),
608+
const preparedFiles = err instanceof DeploymentCreateConflictError ? err.preparedFiles : undefined;
609+
const existing = await adoptOnConflict(err, address, provider, ctx.onFeedback, {
610+
onExisting: (existing) =>
611+
provider.updateDeployment(existing.id!, name, decl, refs, ctx.configPath ?? "", preparedFiles),
612+
});
613+
adopted = true;
614+
return existing;
615+
}
616+
};
617+
if (isUpdate && existingId) {
618+
result = await provider.updateDeployment(existingId, name, decl, refs, ctx.configPath ?? "");
619+
} else {
620+
// A deployment with local file sources uploads before its create request. If the
621+
// remote deployment already exists, an optimistic create would upload once,
622+
// conflict, then upload again during adoption. Preflight this side-effecting
623+
// path so the existing deployment is updated with a single set of uploads.
624+
const existing = hasLocalFileSources ? await findExistingByNames(provider, "deployment", [name]) : null;
625+
if (existing) {
626+
result = await provider.updateDeployment(existing.resource.id!, name, decl, refs, ctx.configPath ?? "");
627+
emitRuntimeFeedback(ctx.onFeedback, {
628+
type: "resource_adopted",
629+
level: "info",
630+
resource: address,
631+
message: `adopt deployment.${name} (${address.provider}) — already existed remotely as "${existing.name}"`,
608632
});
609633
adopted = true;
634+
} else {
635+
result = await materializeDeployment();
610636
}
611637
}
612638
break;

packages/sdk/src/internal/planner/hasher.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,16 @@ export async function computeResourceHash(
2828
}
2929
}
3030

31+
if (address.type === "file" && basePath) {
32+
const fileDecl = decl as { source: string };
33+
const fileHash = computeLocalFileContentHash(fileDecl.source, basePath);
34+
return contentHash({ decl, fileHash });
35+
}
36+
3137
if (address.type === "deployment") {
3238
const refs = resolveDeploymentReferenceIds(decl as DeploymentRefDecl, config, address.provider, state);
33-
if (refs) return contentHash({ decl, refs });
39+
const sourceHashes = basePath ? computeDeploymentSourceHashes(decl as DeploymentRefDecl, basePath) : undefined;
40+
if (refs || sourceHashes) return contentHash({ decl, refs, sourceHashes });
3441
}
3542

3643
if (address.type === "template") {
@@ -77,6 +84,7 @@ function resolveChannelReferenceIds(
7784
interface DeploymentRefDecl {
7885
agent: string;
7986
environment?: string;
87+
resources?: Array<{ type: string; file_id?: string; source?: string }>;
8088
}
8189

8290
interface TemplateRefDecl {
@@ -150,6 +158,26 @@ function getDeclaration(address: ResourceAddress, config: ProjectConfig): unknow
150158
return getResourceDeclaration(address, config);
151159
}
152160

161+
function computeDeploymentSourceHashes(decl: DeploymentRefDecl, basePath: string): Record<string, string> | undefined {
162+
const sources = [
163+
...new Set(
164+
(decl.resources ?? []).flatMap((resource) =>
165+
resource.type === "file" && !resource.file_id && resource.source ? [resource.source] : [],
166+
),
167+
),
168+
];
169+
if (sources.length === 0) return undefined;
170+
171+
return Object.fromEntries(sources.map((source) => [source, computeLocalFileContentHash(source, basePath)]));
172+
}
173+
174+
export function computeLocalFileContentHash(source: string, basePath: string): string {
175+
const fullPath = resolve(dirname(basePath), source);
176+
const stat = statSync(fullPath, { throwIfNoEntry: false });
177+
if (!stat?.isFile()) return "";
178+
return contentHash(readFileSync(fullPath).toString("base64"));
179+
}
180+
153181
export function computeSkillContentHash(source: string, basePath: string): string {
154182
const fullPath = resolve(dirname(basePath), source);
155183
const stat = statSync(fullPath, { throwIfNoEntry: false });

packages/sdk/src/internal/planner/planner.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
} from "../core/validate-config.ts";
66
import { DiagnosticCollector } from "../diagnostics/diagnostics.ts";
77
import { buildDependencyGraph, type DependencyGraph, topologicalSort } from "../graph/dependency.ts";
8+
import { getProvider } from "../providers/registry.ts";
89
import type { ProjectConfig } from "../types/config.ts";
910
import type { ExecutionPlan, PlannedAction } from "../types/plan.ts";
1011
import type { ResourceAddress, StateFile } from "../types/state.ts";
@@ -53,6 +54,10 @@ export async function buildPlan(
5354
const desiredHash = await computeResourceHash(address, config, options.configPath, hashStateLookup);
5455
const existing = stateIndex.get(key);
5556
const deps = getDependencies(address, graph);
57+
const needsNativeDeploymentMaterialization =
58+
address.type === "deployment" &&
59+
existing?.remote_id === null &&
60+
getProvider(address.provider)?.capabilities.deployment.tier === "native";
5661

5762
if (address.type === "environment" && existing) {
5863
const envDecl = config.environments?.[address.name];
@@ -134,6 +139,17 @@ export async function buildPlan(
134139
after: { content_hash: desiredHash },
135140
dependencies: deps,
136141
});
142+
} else if (needsNativeDeploymentMaterialization) {
143+
actions.push({
144+
action: "update",
145+
address,
146+
driftKind: "none",
147+
readinessImpact: "blocking",
148+
reason: "Materialize legacy state as a native deployment",
149+
before: { content_hash: existing.desired_hash ?? existing.content_hash },
150+
after: { content_hash: desiredHash },
151+
dependencies: deps,
152+
});
137153
} else if (
138154
(existing.desired_hash ?? existing.content_hash) !== desiredHash &&
139155
existing.drift_status === "drifted"

packages/sdk/src/internal/providers/bailian/adapter.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import type { ProviderSkillInfo } from "../../types/skill-info.ts";
3434
import type { ResourceType } from "../../types/state.ts";
3535
import { compactDeep, stripAgentsMetadata } from "../../utils/comparable.ts";
3636
import { toRemoteResource } from "../base-client.ts";
37+
import { preserveDeploymentFilesOnConflict } from "../deployment-conflict.ts";
3738
import type {
3839
ComparableRemoteResource,
3940
DeploymentContext,
@@ -562,8 +563,12 @@ export class BailianAdapter implements ProviderAdapter {
562563
): Promise<RemoteResource> {
563564
const uploaded = await this.uploadDeploymentFiles(decl, basePath);
564565
const body = mapDeployment(name, decl, refs, this.projectName, uploaded);
565-
const res = (await this.client.post("/deployments", body)) as Record<string, unknown>;
566-
return toRemoteResource(res);
566+
try {
567+
const res = (await this.client.post("/deployments", body)) as Record<string, unknown>;
568+
return toRemoteResource(res);
569+
} catch (error) {
570+
preserveDeploymentFilesOnConflict(error, uploaded);
571+
}
567572
}
568573

569574
async updateDeployment(
@@ -572,19 +577,15 @@ export class BailianAdapter implements ProviderAdapter {
572577
decl: DeploymentDecl,
573578
refs: ResolvedDeploymentRefs,
574579
basePath: string,
580+
preparedFiles?: ReadonlyMap<string, string>,
575581
): Promise<RemoteResource> {
576582
// Deployments used to be emulated here, so state rows written before native
577583
// support carry `remote_id: null`. An update against one has nothing to PATCH —
578584
// materialize it remotely instead of failing on an empty path segment.
579585
if (!id) return this.createDeployment(name, decl, refs, basePath);
580586

581-
const uploaded = await this.uploadDeploymentFiles(decl, basePath);
582587
const current = (await this.client.get(`/deployments/${id}`)) as Record<string, unknown>;
583-
if (current.schedule && !decl.schedule) {
584-
throw new UserError(
585-
`Deployment '${name}' cannot remove its schedule through the documented Bailian update API; archive and recreate it as a manual deployment.`,
586-
);
587-
}
588+
const uploaded = preparedFiles ? new Map(preparedFiles) : await this.uploadDeploymentFiles(decl, basePath);
588589
const body = mapDeploymentUpdate(
589590
name,
590591
decl,
@@ -815,7 +816,10 @@ function toDeploymentInfo(res: Record<string, unknown>): DeploymentInfo {
815816
status: (res.status as string) ?? "unknown",
816817
paused_reason: (res.paused_reason as DeploymentInfo["paused_reason"] | null | undefined) ?? undefined,
817818
schedule: schedule
818-
? { expression: schedule.expression as string, timezone: schedule.timezone as string | undefined }
819+
? {
820+
expression: schedule.expression as string,
821+
timezone: schedule.timezone as string | undefined,
822+
}
819823
: undefined,
820824
attributes: res,
821825
};

packages/sdk/src/internal/providers/bailian/mapper.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,10 @@ export function agentToDecl(raw: Record<string, unknown>): Record<string, unknow
162162
if (tools?.length) {
163163
const toolset = tools.find((t) => t.type === "builtin_toolkit");
164164
if (toolset) {
165-
const configs = (toolset.configs ?? []) as Array<{ name: string; enabled?: boolean }>;
165+
const configs = (toolset.configs ?? []) as Array<{
166+
name: string;
167+
enabled?: boolean;
168+
}>;
166169
builtinTools = configs.filter((c) => c.enabled !== false).map((c) => c.name);
167170
}
168171
}
@@ -236,7 +239,9 @@ export function mapAgent(
236239
// Tools: builtin_toolkit + mcp_toolkit blocks
237240
const BAILIAN_BUILTINS = new Set(["bash", "read", "write", "edit", "glob", "grep", "download_file"]);
238241
if (decl.tools) {
239-
const toolConfigs = resolveBuiltinTools(decl.tools, { supportedWireNames: BAILIAN_BUILTINS }).map((tool) => ({
242+
const toolConfigs = resolveBuiltinTools(decl.tools, {
243+
supportedWireNames: BAILIAN_BUILTINS,
244+
}).map((tool) => ({
240245
name: tool.wireName,
241246
enabled: true,
242247
}));
@@ -368,8 +373,7 @@ export function mapDeployment(
368373
/**
369374
* Update replaces only the fields present in the payload, so every optional field a
370375
* deployment can drop locally must be sent explicitly to be cleared remotely.
371-
* `schedule` is the exception — it has no documented null form, so removing one is
372-
* rejected in the adapter instead of silently persisting the old cron.
376+
* In particular, `schedule: null` switches a scheduled deployment back to manual.
373377
*/
374378
export function mapDeploymentUpdate(
375379
name: string,
@@ -383,6 +387,7 @@ export function mapDeploymentUpdate(
383387
body.description = decl.description ?? "";
384388
body.vault_ids = refs.vault_ids;
385389
body.resources = mapDeploymentResources(decl, uploadedFiles);
390+
if (!decl.schedule) body.schedule = null;
386391
if (!projectName && !decl.metadata && existingMetadata) body.metadata = existingMetadata;
387392
return body;
388393
}
@@ -396,7 +401,7 @@ function mapDeploymentResources(decl: DeploymentDecl, uploadedFiles?: Map<string
396401
const fileId = resource.file_id ?? (resource.source ? uploadedFiles?.get(resource.source) : undefined);
397402
if (!fileId) continue;
398403
const entry: Record<string, unknown> = { type: "file", file_id: fileId };
399-
if (resource.mount_path) entry.mount_path = resource.mount_path;
404+
if (resource.mount_path) entry.mount_path = resolveSandboxMountPath("bailian", resource.mount_path);
400405
resources.push(entry);
401406
}
402407
return resources;

packages/sdk/src/internal/providers/claude/adapter.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { ResourceType } from "../../types/state.ts";
3232
import { extractSkillZipFiles } from "../../utils/normalize-skill-zip.ts";
3333
import { skillNameFromFiles } from "../../utils/skill-manifest.ts";
3434
import { toRemoteResource } from "../base-client.ts";
35+
import { preserveDeploymentFilesOnConflict } from "../deployment-conflict.ts";
3536
import type {
3637
DeploymentContext,
3738
DeploymentInfo,
@@ -368,8 +369,12 @@ export class ClaudeAdapter implements ProviderAdapter {
368369
): Promise<RemoteResource> {
369370
const uploaded = await this.uploadDeploymentFiles(decl, basePath);
370371
const body = mapDeployment(name, decl, refs, this.projectName, uploaded);
371-
const res = (await this.client.post("/deployments", body)) as Record<string, unknown>;
372-
return toRemoteResource(res);
372+
try {
373+
const res = (await this.client.post("/deployments", body)) as Record<string, unknown>;
374+
return toRemoteResource(res);
375+
} catch (error) {
376+
preserveDeploymentFilesOnConflict(error, uploaded);
377+
}
373378
}
374379

375380
async updateDeployment(
@@ -378,8 +383,9 @@ export class ClaudeAdapter implements ProviderAdapter {
378383
decl: DeploymentDecl,
379384
refs: ResolvedDeploymentRefs,
380385
basePath: string,
386+
preparedFiles?: ReadonlyMap<string, string>,
381387
): Promise<RemoteResource> {
382-
const uploaded = await this.uploadDeploymentFiles(decl, basePath);
388+
const uploaded = preparedFiles ? new Map(preparedFiles) : await this.uploadDeploymentFiles(decl, basePath);
383389
const current = (await this.client.get(`/deployments/${id}`)) as Record<string, unknown>;
384390
if (current.schedule && !decl.schedule) {
385391
throw new UserError(

0 commit comments

Comments
 (0)