mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
Create Claw-managed workspace files (#101973)
* Create Claw-managed workspace files * test(claws): cover managed workspace files * docs(claws): document workspace files * docs(claws): correct workspace manifest example * fix(claws): preserve failed workspace ownership boundaries * test(claws): align symlink escape diagnostic * test(claws): canonicalize workspace alias fixture * test(claws): clean workspace temp dirs --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
This commit is contained in:
+24
-3
@@ -47,6 +47,26 @@ limited to 1 MiB, package metadata to 256 KiB, and workspace sources enforce
|
||||
separate per-file and aggregate limits. Workspace sources also reject symlinked
|
||||
parents.
|
||||
|
||||
Workspace files are declared by path and read from package sidecars. Bootstrap
|
||||
files such as `SOUL.md` use named entries; additional files use package-relative
|
||||
sources and workspace-relative targets:
|
||||
|
||||
```json
|
||||
{
|
||||
"workspace": {
|
||||
"bootstrapFiles": {
|
||||
"SOUL.md": { "source": "workspace/SOUL.md" }
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"source": "workspace/reference/policy.md",
|
||||
"path": "reference/policy.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Inspect and preview
|
||||
|
||||
Validate the source without planning local changes:
|
||||
@@ -77,9 +97,10 @@ when the source, destination, or live configuration changed after preview. Use
|
||||
`--agent-id` or `--workspace` during both preview and apply when package
|
||||
defaults collide with local state.
|
||||
|
||||
At this stage, adding a Claw creates the new agent and workspace configuration
|
||||
and records installation provenance. Later Claws stages add managed workspace
|
||||
files and other declared resources.
|
||||
Adding a Claw creates the new agent and workspace configuration, writes declared
|
||||
workspace files, and records installation and per-file provenance. Existing
|
||||
files are not overwritten, and retries fail closed when owned content drifted.
|
||||
Later Claws stages add other declared resources.
|
||||
|
||||
## Command reference
|
||||
|
||||
|
||||
+65
-4
@@ -1,4 +1,4 @@
|
||||
// Applies the narrow agent/workspace creation slice of a consented Claw add plan.
|
||||
// Applies the agent, workspace, and managed-file slice of a consented Claw add plan.
|
||||
import { lstat, mkdir, rmdir } from "node:fs/promises";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { findOverlappingWorkspaceAgentIds } from "../agents/agent-delete-safety.js";
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
type PersistedClawInstall,
|
||||
} from "./provenance.js";
|
||||
import { CLAW_OUTPUT_STABILITY, type ClawAddPlan } from "./types.js";
|
||||
import {
|
||||
ClawWorkspaceWriteError,
|
||||
createClawWorkspaceFiles,
|
||||
type PersistedClawWorkspaceFile,
|
||||
} from "./workspace.js";
|
||||
|
||||
export const CLAW_ADD_RESULT_SCHEMA_VERSION = "openclaw.clawAddResult.v1" as const;
|
||||
|
||||
@@ -28,6 +33,7 @@ type ClawAddApplyOptions = OpenClawStateDatabaseOptions & {
|
||||
persistRecord?: typeof persistClawInstallRecord;
|
||||
deleteRecord?: typeof deleteClawInstallRecord;
|
||||
updateRecord?: typeof updateClawInstallRecordStatus;
|
||||
createWorkspaceFiles?: typeof createClawWorkspaceFiles;
|
||||
nowMs?: number;
|
||||
};
|
||||
type AgentConfig = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
|
||||
@@ -53,12 +59,19 @@ type ClawAddResult = {
|
||||
agent: ClawAddPlan["agent"];
|
||||
workspaceCreated: boolean;
|
||||
configCommitted: boolean;
|
||||
workspaceFiles: PersistedClawWorkspaceFile[];
|
||||
installRecord?: PersistedClawInstall;
|
||||
error?: { code: string; message: string };
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
diagnostics?: ClawWorkspaceWriteError["diagnostics"];
|
||||
};
|
||||
};
|
||||
|
||||
function hasUnsupportedMutationActions(plan: ClawAddPlan): boolean {
|
||||
return plan.actions.some((action) => !["agent", "workspace"].includes(action.kind));
|
||||
return plan.actions.some(
|
||||
(action) => !["agent", "workspace", "workspaceFile"].includes(action.kind),
|
||||
);
|
||||
}
|
||||
|
||||
function statusAtLeast(status: ClawInstallStatus, phase: ClawInstallStatus): boolean {
|
||||
@@ -123,7 +136,7 @@ export async function applyClawAddPlan(
|
||||
if (hasUnsupportedMutationActions(plan)) {
|
||||
throw new ClawAddMutationError(
|
||||
"unsupported_components",
|
||||
"This build can only add Claws with agent settings and an empty workspace; declared files, packages, MCP servers, or cron jobs require later lifecycle slices.",
|
||||
"This build can add agent settings and workspace files; declared packages, MCP servers, or cron jobs require later lifecycle slices.",
|
||||
);
|
||||
}
|
||||
if (options.consentPlanIntegrity !== plan.planIntegrity) {
|
||||
@@ -284,6 +297,52 @@ export async function applyClawAddPlan(
|
||||
throw error;
|
||||
}
|
||||
|
||||
const createFiles = options.createWorkspaceFiles ?? createClawWorkspaceFiles;
|
||||
let workspaceFiles: PersistedClawWorkspaceFile[] = [];
|
||||
try {
|
||||
workspaceFiles = await createFiles(plan, options);
|
||||
} catch (error) {
|
||||
const workspaceError =
|
||||
error instanceof ClawWorkspaceWriteError
|
||||
? error
|
||||
: new ClawWorkspaceWriteError(
|
||||
[
|
||||
{
|
||||
level: "error",
|
||||
code: "workspace_file_io_error",
|
||||
phase: "mutation",
|
||||
path: "$.workspace",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
workspaceFiles,
|
||||
);
|
||||
markInstallStatus(plan.agent.finalId, "config_committed", ["config_committed"], options);
|
||||
return {
|
||||
schemaVersion: CLAW_ADD_RESULT_SCHEMA_VERSION,
|
||||
stability: CLAW_OUTPUT_STABILITY,
|
||||
dryRun: false,
|
||||
mutationAllowed: true,
|
||||
planIntegrity: plan.planIntegrity,
|
||||
status: "partial",
|
||||
claw: plan.claw,
|
||||
agent: plan.agent,
|
||||
workspaceCreated,
|
||||
configCommitted,
|
||||
workspaceFiles: workspaceError.createdFiles,
|
||||
installRecord: {
|
||||
...installRecord,
|
||||
status: "config_committed",
|
||||
updatedAtMs: options.nowMs ?? Date.now(),
|
||||
},
|
||||
error: {
|
||||
code: "workspace_files_failed",
|
||||
message: workspaceError.message,
|
||||
diagnostics: workspaceError.diagnostics,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
markInstallStatus(plan.agent.finalId, "complete", ["config_committed", "complete"], options);
|
||||
return {
|
||||
@@ -297,6 +356,7 @@ export async function applyClawAddPlan(
|
||||
agent: plan.agent,
|
||||
workspaceCreated,
|
||||
configCommitted,
|
||||
workspaceFiles,
|
||||
installRecord: {
|
||||
...installRecord,
|
||||
status: "complete",
|
||||
@@ -315,6 +375,7 @@ export async function applyClawAddPlan(
|
||||
agent: plan.agent,
|
||||
workspaceCreated,
|
||||
configCommitted,
|
||||
workspaceFiles,
|
||||
error: { code: "provenance_failed", message: (error as Error).message },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"agent": {
|
||||
"id": "workspace-agent",
|
||||
"name": "Workspace Agent",
|
||||
"identity": {
|
||||
"name": "Workspace"
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"bootstrapFiles": {
|
||||
"SOUL.md": { "source": "workspace/SOUL.md" },
|
||||
"HEARTBEAT.md": { "source": "workspace/HEARTBEAT.md" }
|
||||
},
|
||||
"files": [
|
||||
{
|
||||
"source": "workspace/reference/policy.md",
|
||||
"path": "reference/policy.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Workspace Policy
|
||||
|
||||
Keep operator settings and unrelated agents unchanged.
|
||||
@@ -150,6 +150,52 @@ describe("claws lifecycle cli e2e", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates declared bootstrap and supporting files in the new workspace", async () => {
|
||||
const preview = await runOpenClaw([
|
||||
"claws",
|
||||
"add",
|
||||
"src/claws/fixtures/workspace-agent.claw.json",
|
||||
"--dry-run",
|
||||
"--json",
|
||||
]);
|
||||
const plan = parseJson(preview.stdout) as { planIntegrity: string };
|
||||
const result = await runOpenClaw(
|
||||
[
|
||||
"claws",
|
||||
"add",
|
||||
"src/claws/fixtures/workspace-agent.claw.json",
|
||||
"--yes",
|
||||
"--plan-integrity",
|
||||
plan.planIntegrity,
|
||||
"--json",
|
||||
],
|
||||
{ stateDir: preview.stateDir },
|
||||
);
|
||||
const payload = parseJson(result.stdout);
|
||||
const workspace = join(result.stateDir, ".openclaw", "workspace-workspace-agent");
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
schemaVersion: "openclaw.clawAddResult.v1",
|
||||
status: "complete",
|
||||
agent: { finalId: "workspace-agent", workspace },
|
||||
workspaceFiles: [
|
||||
expect.objectContaining({ path: "SOUL.md" }),
|
||||
expect.objectContaining({ path: "HEARTBEAT.md" }),
|
||||
expect.objectContaining({ path: "reference/policy.md" }),
|
||||
],
|
||||
installRecord: { agentId: "workspace-agent", status: "complete" },
|
||||
});
|
||||
await expect(readFile(join(workspace, "SOUL.md"), "utf8")).resolves.toContain(
|
||||
"Incident Response",
|
||||
);
|
||||
await expect(readFile(join(workspace, "HEARTBEAT.md"), "utf8")).resolves.toContain(
|
||||
"Incident Heartbeat",
|
||||
);
|
||||
await expect(readFile(join(workspace, "reference", "policy.md"), "utf8")).resolves.toContain(
|
||||
"operator settings",
|
||||
);
|
||||
});
|
||||
|
||||
it("blocks mutation when declared components need later lifecycle slices", async () => {
|
||||
const preview = await runOpenClaw(["claws", "add", manifestPath, "--dry-run", "--json"], {
|
||||
expectFailure: true,
|
||||
|
||||
@@ -577,6 +577,30 @@ describe("buildClawAddPlan", () => {
|
||||
expect(workspaceAction).not.toHaveProperty("digest");
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"canonicalizes workspace identity through symlinked parents",
|
||||
async () => {
|
||||
const { source } = await createPlanSource();
|
||||
const realParent = join(source.packageRoot, "real-parent");
|
||||
const aliasParent = join(source.packageRoot, "alias-parent");
|
||||
await mkdir(realParent, { recursive: true });
|
||||
await symlink(realParent, aliasParent, "dir");
|
||||
|
||||
const plan = await buildClawAddPlan({
|
||||
manifest: requireManifest({ schemaVersion: 1, agent: { id: "canonical-agent" } }),
|
||||
source,
|
||||
context: { workspace: join(aliasParent, "workspace-canonical-agent") },
|
||||
});
|
||||
|
||||
const canonicalWorkspace = join(realParent, "workspace-canonical-agent");
|
||||
expect(plan.agent.workspace).toBe(canonicalWorkspace);
|
||||
expect(plan.agent.config.workspace).toBe(canonicalWorkspace);
|
||||
expect(plan.actions.find((action) => action.kind === "workspace")?.target).toBe(
|
||||
canonicalWorkspace,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("blocks aggregate workspace bytes before hashing sources", async () => {
|
||||
const { source, workspace } = await createPlanSource();
|
||||
const files = [];
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
// Tests create-only Claw workspace files and immediate per-file provenance.
|
||||
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
closeOpenClawStateDatabaseForTest,
|
||||
openOpenClawStateDatabase,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import { applyClawAddPlan } from "./add.js";
|
||||
import { buildClawAddPlan } from "./lifecycle.js";
|
||||
import { parseClawManifest } from "./schema.js";
|
||||
import type { ClawAddPlan, ClawSourceIdentity } from "./types.js";
|
||||
import { ClawWorkspaceWriteError, createClawWorkspaceFiles } from "./workspace.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
});
|
||||
|
||||
async function writeSource(root: string, path: string, content: string): Promise<void> {
|
||||
const target = join(root, path);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, content, "utf8");
|
||||
}
|
||||
|
||||
async function makePlan(params?: {
|
||||
workspace?: unknown;
|
||||
createWorkspace?: boolean;
|
||||
mutateAfterPlan?: (plan: ClawAddPlan, root: string) => Promise<void>;
|
||||
}) {
|
||||
const root = tempDirs.make("openclaw-claw-workspace-");
|
||||
const workspace = join(root, "workspace-agent");
|
||||
await writeSource(root, "content/AGENTS.md", "# Agent\n");
|
||||
await writeSource(root, "content/policy.md", "Policy\n");
|
||||
const parsed = parseClawManifest({
|
||||
schemaVersion: 1,
|
||||
agent: { id: "workspace-agent" },
|
||||
workspace: params?.workspace ?? {
|
||||
bootstrapFiles: { "AGENTS.md": { source: "content/AGENTS.md" } },
|
||||
files: [{ source: "content/policy.md", path: "reference/policy.md" }],
|
||||
},
|
||||
});
|
||||
if (!parsed.ok) {
|
||||
throw new Error(JSON.stringify(parsed.diagnostics));
|
||||
}
|
||||
const source: ClawSourceIdentity = {
|
||||
kind: "package",
|
||||
name: "@acme/workspace-agent",
|
||||
version: "1.0.0",
|
||||
packageRoot: root,
|
||||
manifestPath: join(root, "openclaw.claw.json"),
|
||||
integrityKind: "development-snapshot",
|
||||
integrity: "sha256:manifest",
|
||||
byteLength: 0,
|
||||
};
|
||||
const plan = await buildClawAddPlan({
|
||||
manifest: parsed.manifest,
|
||||
source,
|
||||
context: { workspace },
|
||||
});
|
||||
expect(plan.blockers).toEqual([]);
|
||||
if (params?.createWorkspace !== false) {
|
||||
await mkdir(workspace);
|
||||
}
|
||||
await params?.mutateAfterPlan?.(plan, root);
|
||||
return { root, workspace, plan };
|
||||
}
|
||||
|
||||
function stateEnv(root: string) {
|
||||
return { OPENCLAW_STATE_DIR: join(root, "state") };
|
||||
}
|
||||
|
||||
type WorkspaceFileRow = {
|
||||
schema_version: string;
|
||||
agent_id: string;
|
||||
workspace: string;
|
||||
target_path: string;
|
||||
source_path: string;
|
||||
content_digest: string;
|
||||
status: "pending" | "complete" | "failed";
|
||||
created_at_ms: number | bigint;
|
||||
updated_at_ms: number | bigint;
|
||||
};
|
||||
|
||||
function readWorkspaceFileRows(agentId: string, root: string) {
|
||||
const rows = openOpenClawStateDatabase({ env: stateEnv(root) })
|
||||
.db.prepare(
|
||||
`SELECT schema_version, agent_id, workspace, target_path, source_path,
|
||||
content_digest, status, created_at_ms, updated_at_ms
|
||||
FROM claw_workspace_files
|
||||
WHERE agent_id = ?
|
||||
ORDER BY target_path`,
|
||||
)
|
||||
.all(agentId) as WorkspaceFileRow[];
|
||||
return rows.map((row) => ({
|
||||
schemaVersion: "openclaw.clawWorkspaceFileRecord.v1" as const,
|
||||
agentId: row.agent_id,
|
||||
workspace: row.workspace,
|
||||
path: row.target_path,
|
||||
sourcePath: row.source_path,
|
||||
contentDigest: row.content_digest,
|
||||
status: row.status,
|
||||
createdAtMs: Number(row.created_at_ms),
|
||||
updatedAtMs: Number(row.updated_at_ms),
|
||||
}));
|
||||
}
|
||||
|
||||
function readInstallStatus(agentId: string, root: string): string | undefined {
|
||||
const row = openOpenClawStateDatabase({ env: stateEnv(root) })
|
||||
.db.prepare(`SELECT status FROM claw_installs WHERE agent_id = ?`)
|
||||
.get(agentId) as { status: string } | undefined;
|
||||
return row?.status;
|
||||
}
|
||||
|
||||
describe("createClawWorkspaceFiles", () => {
|
||||
it("creates canonical bootstrap and supporting files and records their hashes", async () => {
|
||||
const { root, workspace, plan } = await makePlan();
|
||||
|
||||
const records = await createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 10 });
|
||||
|
||||
await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).resolves.toBe("# Agent\n");
|
||||
await expect(readFile(join(workspace, "reference", "policy.md"), "utf8")).resolves.toBe(
|
||||
"Policy\n",
|
||||
);
|
||||
expect(records).toEqual([
|
||||
expect.objectContaining({
|
||||
schemaVersion: "openclaw.clawWorkspaceFileRecord.v1",
|
||||
agentId: "workspace-agent",
|
||||
path: "AGENTS.md",
|
||||
sourcePath: "content/AGENTS.md",
|
||||
contentDigest: expect.stringMatching(/^sha256:[a-f0-9]{64}$/),
|
||||
status: "complete",
|
||||
createdAtMs: 10,
|
||||
updatedAtMs: 10,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
agentId: "workspace-agent",
|
||||
path: "reference/policy.md",
|
||||
}),
|
||||
]);
|
||||
expect(readWorkspaceFileRows("workspace-agent", root)).toEqual(records);
|
||||
});
|
||||
|
||||
it("never overwrites an unexpected destination", async () => {
|
||||
const { root, workspace, plan } = await makePlan();
|
||||
await writeFile(join(workspace, "AGENTS.md"), "operator content\n", "utf8");
|
||||
|
||||
await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toMatchObject({
|
||||
diagnostics: [expect.objectContaining({ code: "workspace_file_collision" })],
|
||||
createdFiles: [],
|
||||
});
|
||||
await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).resolves.toBe(
|
||||
"operator content\n",
|
||||
);
|
||||
});
|
||||
|
||||
it("revalidates source content immediately before writing", async () => {
|
||||
const { root, workspace, plan } = await makePlan({
|
||||
mutateAfterPlan: async (_plan, packageRoot) => {
|
||||
await writeFile(join(packageRoot, "content", "AGENTS.md"), "changed\n", "utf8");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toMatchObject({
|
||||
diagnostics: [expect.objectContaining({ code: "workspace_source_changed" })],
|
||||
});
|
||||
await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects a source replaced by a symlink after planning",
|
||||
async () => {
|
||||
const outside = tempDirs.make("openclaw-claw-outside-");
|
||||
await writeFile(join(outside, "outside.md"), "outside\n", "utf8");
|
||||
const { root, workspace, plan } = await makePlan({
|
||||
mutateAfterPlan: async (_plan, packageRoot) => {
|
||||
const source = join(packageRoot, "content", "AGENTS.md");
|
||||
await rm(source);
|
||||
await symlink(join(outside, "outside.md"), source);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toBeInstanceOf(
|
||||
ClawWorkspaceWriteError,
|
||||
);
|
||||
await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"rejects a tampered plan source through a symlinked parent",
|
||||
async () => {
|
||||
const { root, workspace, plan } = await makePlan();
|
||||
await symlink(join(root, "content"), join(root, "content-link"), "dir");
|
||||
const action = plan.actions.find(
|
||||
(candidate) => candidate.kind === "workspaceFile" && candidate.id === "AGENTS.md",
|
||||
);
|
||||
if (!action) {
|
||||
throw new Error("expected workspace action");
|
||||
}
|
||||
action.source = join(plan.claw.packageRoot, "content-link", "AGENTS.md");
|
||||
|
||||
await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toMatchObject({
|
||||
diagnostics: [expect.objectContaining({ code: "workspace_file_path_alias" })],
|
||||
});
|
||||
await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("persists earlier files when a later destination collides", async () => {
|
||||
const { root, workspace, plan } = await makePlan({
|
||||
workspace: {
|
||||
files: [
|
||||
{ source: "content/AGENTS.md", path: "first.md" },
|
||||
{ source: "content/policy.md", path: "second.md" },
|
||||
],
|
||||
},
|
||||
});
|
||||
await writeFile(join(workspace, "second.md"), "collision\n", "utf8");
|
||||
|
||||
await expect(
|
||||
createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 20 }),
|
||||
).rejects.toMatchObject({
|
||||
diagnostics: [expect.objectContaining({ code: "workspace_file_collision" })],
|
||||
createdFiles: [expect.objectContaining({ path: "first.md" })],
|
||||
});
|
||||
await expect(readFile(join(workspace, "first.md"), "utf8")).resolves.toBe("# Agent\n");
|
||||
expect(readWorkspaceFileRows("workspace-agent", root)).toEqual([
|
||||
expect.objectContaining({ path: "first.md", createdAtMs: 20 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("resumes matching owned files without weakening create-only collision checks", async () => {
|
||||
const { root, workspace, plan } = await makePlan();
|
||||
|
||||
const first = await createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 10 });
|
||||
const resumed = await createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 20 });
|
||||
|
||||
expect(resumed).toHaveLength(first.length);
|
||||
for (const [index, record] of resumed.entries()) {
|
||||
const initial = first[index];
|
||||
if (!initial) {
|
||||
throw new Error(`missing initial workspace record at index ${index}`);
|
||||
}
|
||||
expect(record).toEqual({ ...initial, status: "complete", updatedAtMs: 20 });
|
||||
}
|
||||
await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).resolves.toBe("# Agent\n");
|
||||
});
|
||||
|
||||
it("does not adopt an independently created file after a failed write record", async () => {
|
||||
const { root, workspace, plan } = await makePlan();
|
||||
const action = plan.actions.find(
|
||||
(candidate) => candidate.kind === "workspaceFile" && candidate.id === "AGENTS.md",
|
||||
);
|
||||
if (!action?.digest) {
|
||||
throw new Error("expected AGENTS.md workspace action");
|
||||
}
|
||||
openOpenClawStateDatabase({ env: stateEnv(root) })
|
||||
.db.prepare(
|
||||
`INSERT INTO claw_workspace_files (
|
||||
schema_version, agent_id, workspace, target_path, source_path,
|
||||
content_digest, status, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
"openclaw.clawWorkspaceFileRecord.v1",
|
||||
plan.agent.finalId,
|
||||
plan.agent.workspace,
|
||||
"AGENTS.md",
|
||||
"content/AGENTS.md",
|
||||
action.digest,
|
||||
"failed",
|
||||
10,
|
||||
10,
|
||||
);
|
||||
await writeFile(join(workspace, "AGENTS.md"), "# Agent\n", "utf8");
|
||||
|
||||
await expect(
|
||||
createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 20 }),
|
||||
).rejects.toMatchObject({
|
||||
diagnostics: [expect.objectContaining({ code: "workspace_file_collision" })],
|
||||
createdFiles: [],
|
||||
});
|
||||
expect(readWorkspaceFileRows("workspace-agent", root)).toEqual([
|
||||
expect.objectContaining({ path: "AGENTS.md", status: "failed", updatedAtMs: 10 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails closed when a previously owned destination drifts before resume", async () => {
|
||||
const { root, workspace, plan } = await makePlan();
|
||||
await createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 10 });
|
||||
await writeFile(join(workspace, "AGENTS.md"), "operator edit\n", "utf8");
|
||||
|
||||
await expect(
|
||||
createClawWorkspaceFiles(plan, { env: stateEnv(root), nowMs: 20 }),
|
||||
).rejects.toMatchObject({
|
||||
diagnostics: [expect.objectContaining({ code: "workspace_file_drift" })],
|
||||
createdFiles: [],
|
||||
});
|
||||
await expect(readFile(join(workspace, "AGENTS.md"), "utf8")).resolves.toBe("operator edit\n");
|
||||
});
|
||||
|
||||
it("rejects a tampered plan destination outside the new workspace", async () => {
|
||||
const { root, plan } = await makePlan();
|
||||
const action = plan.actions.find((candidate) => candidate.kind === "workspaceFile");
|
||||
if (!action) {
|
||||
throw new Error("expected workspace action");
|
||||
}
|
||||
action.target = join(root, "outside.md");
|
||||
|
||||
await expect(createClawWorkspaceFiles(plan, { env: stateEnv(root) })).rejects.toMatchObject({
|
||||
diagnostics: [expect.objectContaining({ code: "workspace_file_path_escape" })],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace files in the consented add lifecycle", () => {
|
||||
it("marks the root install complete after every declared file is created", async () => {
|
||||
const { root, plan } = await makePlan({ createWorkspace: false });
|
||||
let config: OpenClawConfig = {};
|
||||
|
||||
const result = await applyClawAddPlan(plan, {
|
||||
consentPlanIntegrity: plan.planIntegrity,
|
||||
env: stateEnv(root),
|
||||
nowMs: 30,
|
||||
commitConfig: async (transform) => {
|
||||
config = transform(config);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "complete",
|
||||
workspaceFiles: [
|
||||
expect.objectContaining({ path: "AGENTS.md" }),
|
||||
expect.objectContaining({ path: "reference/policy.md" }),
|
||||
],
|
||||
installRecord: { status: "complete" },
|
||||
});
|
||||
expect(config.agents?.list?.some((agent) => agent.id === "workspace-agent")).toBe(true);
|
||||
expect(readInstallStatus("workspace-agent", root)).toBe("complete");
|
||||
});
|
||||
|
||||
it("keeps root add resumable and retains earlier file refs after a later source changes", async () => {
|
||||
const { root, plan } = await makePlan({
|
||||
createWorkspace: false,
|
||||
mutateAfterPlan: async (_plan, packageRoot) => {
|
||||
await writeFile(join(packageRoot, "content", "policy.md"), "changed\n", "utf8");
|
||||
},
|
||||
});
|
||||
let config: OpenClawConfig = {};
|
||||
|
||||
const result = await applyClawAddPlan(plan, {
|
||||
consentPlanIntegrity: plan.planIntegrity,
|
||||
env: stateEnv(root),
|
||||
nowMs: 40,
|
||||
commitConfig: async (transform) => {
|
||||
config = transform(config);
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: "partial",
|
||||
workspaceFiles: [expect.objectContaining({ path: "AGENTS.md" })],
|
||||
installRecord: { status: "config_committed" },
|
||||
error: {
|
||||
code: "workspace_files_failed",
|
||||
diagnostics: [expect.objectContaining({ code: "workspace_source_changed" })],
|
||||
},
|
||||
});
|
||||
expect(config.agents?.list?.some((agent) => agent.id === "workspace-agent")).toBe(true);
|
||||
expect(readInstallStatus("workspace-agent", root)).toBe("config_committed");
|
||||
|
||||
await writeFile(join(root, "content", "policy.md"), "Policy\n", "utf8");
|
||||
const resumed = await applyClawAddPlan(plan, {
|
||||
consentPlanIntegrity: plan.planIntegrity,
|
||||
env: stateEnv(root),
|
||||
nowMs: 50,
|
||||
commitConfig: async (transform) => {
|
||||
config = transform(config);
|
||||
},
|
||||
});
|
||||
|
||||
expect(resumed).toMatchObject({
|
||||
status: "complete",
|
||||
workspaceFiles: [
|
||||
expect.objectContaining({ path: "AGENTS.md", status: "complete" }),
|
||||
expect.objectContaining({ path: "reference/policy.md", status: "complete" }),
|
||||
],
|
||||
installRecord: { status: "complete" },
|
||||
});
|
||||
expect(readInstallStatus("workspace-agent", root)).toBe("complete");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
// Creates Claw-owned bootstrap and supporting files inside the new agent workspace.
|
||||
import { createHash } from "node:crypto";
|
||||
import { realpath } from "node:fs/promises";
|
||||
import { isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { root as fsSafeRoot, FsSafeError } from "../infra/fs-safe.js";
|
||||
import {
|
||||
runOpenClawStateWriteTransaction,
|
||||
type OpenClawStateDatabaseOptions,
|
||||
} from "../state/openclaw-state-db.js";
|
||||
import type { ClawAddPlan, ClawAddPlanAction, ClawDiagnostic } from "./types.js";
|
||||
|
||||
const CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION = "openclaw.clawWorkspaceFileRecord.v1" as const;
|
||||
|
||||
const MAX_CLAW_WORKSPACE_FILE_BYTES = 1024 * 1024;
|
||||
|
||||
export type PersistedClawWorkspaceFile = {
|
||||
schemaVersion: typeof CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION;
|
||||
agentId: string;
|
||||
workspace: string;
|
||||
path: string;
|
||||
sourcePath: string;
|
||||
contentDigest: string;
|
||||
status: "pending" | "complete" | "failed";
|
||||
createdAtMs: number;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
export class ClawWorkspaceWriteError extends Error {
|
||||
constructor(
|
||||
readonly diagnostics: ClawDiagnostic[],
|
||||
readonly createdFiles: PersistedClawWorkspaceFile[],
|
||||
) {
|
||||
super("Claw workspace file creation failed");
|
||||
this.name = "ClawWorkspaceWriteError";
|
||||
}
|
||||
}
|
||||
|
||||
function diagnostic(action: ClawAddPlanAction, code: string, message: string): ClawDiagnostic {
|
||||
return {
|
||||
level: "error",
|
||||
code,
|
||||
phase: "mutation",
|
||||
path: `$.workspace[${JSON.stringify(action.id)}]`,
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function contentDigest(content: Uint8Array): string {
|
||||
return `sha256:${createHash("sha256").update(content).digest("hex")}`;
|
||||
}
|
||||
|
||||
function containedRelativePath(root: string, path: string): string | undefined {
|
||||
const child = relative(root, path);
|
||||
if (child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)) {
|
||||
return undefined;
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
function persistWorkspaceFile(
|
||||
record: PersistedClawWorkspaceFile,
|
||||
options: OpenClawStateDatabaseOptions,
|
||||
): void {
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
// sqlite-allow-raw: this Claw prototype state-table write is scoped to one owned row.
|
||||
db.prepare(
|
||||
`INSERT INTO claw_workspace_files (
|
||||
agent_id, target_path, schema_version, workspace, source_path,
|
||||
content_digest, status, created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
@agent_id, @target_path, @schema_version, @workspace, @source_path,
|
||||
@content_digest, @status, @created_at_ms, @updated_at_ms
|
||||
)`,
|
||||
).run({
|
||||
agent_id: record.agentId,
|
||||
target_path: record.path,
|
||||
schema_version: record.schemaVersion,
|
||||
workspace: record.workspace,
|
||||
source_path: record.sourcePath,
|
||||
content_digest: record.contentDigest,
|
||||
status: record.status,
|
||||
created_at_ms: record.createdAtMs,
|
||||
updated_at_ms: record.updatedAtMs,
|
||||
});
|
||||
}, options);
|
||||
}
|
||||
|
||||
type PersistedClawWorkspaceFileRow = {
|
||||
schema_version: string;
|
||||
agent_id: string;
|
||||
workspace: string;
|
||||
target_path: string;
|
||||
source_path: string;
|
||||
content_digest: string;
|
||||
status: string;
|
||||
created_at_ms: number | bigint;
|
||||
updated_at_ms: number | bigint;
|
||||
};
|
||||
|
||||
function readWorkspaceFile(
|
||||
agentId: string,
|
||||
targetPath: string,
|
||||
options: OpenClawStateDatabaseOptions,
|
||||
): PersistedClawWorkspaceFile | undefined {
|
||||
return runOpenClawStateWriteTransaction(({ db }) => {
|
||||
const statement = db /* sqlite-allow-raw: one owned Claw state-table row */
|
||||
.prepare(
|
||||
`SELECT schema_version, agent_id, workspace, target_path, source_path,
|
||||
content_digest, status, created_at_ms, updated_at_ms
|
||||
FROM claw_workspace_files
|
||||
WHERE agent_id = ? AND target_path = ?`,
|
||||
);
|
||||
const row = statement.get(agentId, targetPath) as PersistedClawWorkspaceFileRow | undefined;
|
||||
if (!row) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
row.schema_version !== CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION ||
|
||||
(row.status !== "pending" && row.status !== "complete" && row.status !== "failed")
|
||||
) {
|
||||
throw new Error(
|
||||
`Claw workspace file ${JSON.stringify(targetPath)} has unsupported provenance state.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
schemaVersion: CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION,
|
||||
agentId: row.agent_id,
|
||||
workspace: row.workspace,
|
||||
path: row.target_path,
|
||||
sourcePath: row.source_path,
|
||||
contentDigest: row.content_digest,
|
||||
status: row.status,
|
||||
createdAtMs: Number(row.created_at_ms),
|
||||
updatedAtMs: Number(row.updated_at_ms),
|
||||
};
|
||||
}, options);
|
||||
}
|
||||
|
||||
function sameWorkspaceFileOwner(
|
||||
existing: PersistedClawWorkspaceFile,
|
||||
expected: PersistedClawWorkspaceFile,
|
||||
): boolean {
|
||||
return (
|
||||
existing.schemaVersion === expected.schemaVersion &&
|
||||
existing.agentId === expected.agentId &&
|
||||
existing.workspace === expected.workspace &&
|
||||
existing.path === expected.path &&
|
||||
existing.sourcePath === expected.sourcePath &&
|
||||
existing.contentDigest === expected.contentDigest
|
||||
);
|
||||
}
|
||||
|
||||
function updateWorkspaceFileStatus(
|
||||
record: PersistedClawWorkspaceFile,
|
||||
expectedStatuses: PersistedClawWorkspaceFile["status"][],
|
||||
options: OpenClawStateDatabaseOptions,
|
||||
): void {
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
const expectedPlaceholders = expectedStatuses.map(() => "?").join(", ");
|
||||
const statement = db /* sqlite-allow-raw: one owned Claw state-table row */
|
||||
.prepare(
|
||||
`UPDATE claw_workspace_files
|
||||
SET status = ?, updated_at_ms = ?
|
||||
WHERE agent_id = ? AND target_path = ?
|
||||
AND status IN (${expectedPlaceholders})`,
|
||||
);
|
||||
const result = statement.run(
|
||||
record.status,
|
||||
record.updatedAtMs,
|
||||
record.agentId,
|
||||
record.path,
|
||||
...expectedStatuses,
|
||||
);
|
||||
if (Number(result.changes) !== 1) {
|
||||
throw new Error(
|
||||
`Claw workspace file ${JSON.stringify(record.path)} changed ownership state concurrently.`,
|
||||
);
|
||||
}
|
||||
}, options);
|
||||
}
|
||||
|
||||
function workspaceFileActions(plan: ClawAddPlan): ClawAddPlanAction[] {
|
||||
return plan.actions.filter((action) => action.kind === "workspaceFile");
|
||||
}
|
||||
|
||||
export async function createClawWorkspaceFiles(
|
||||
plan: ClawAddPlan,
|
||||
options: OpenClawStateDatabaseOptions & { nowMs?: number } = {},
|
||||
): Promise<PersistedClawWorkspaceFile[]> {
|
||||
const actions = workspaceFileActions(plan);
|
||||
if (actions.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspaceRoot = await realpath(resolve(plan.agent.workspace));
|
||||
const packageRoot = await realpath(resolve(plan.claw.packageRoot));
|
||||
const source = await fsSafeRoot(packageRoot, {
|
||||
hardlinks: "reject",
|
||||
maxBytes: MAX_CLAW_WORKSPACE_FILE_BYTES,
|
||||
symlinks: "reject",
|
||||
});
|
||||
const workspace = await fsSafeRoot(workspaceRoot, {
|
||||
hardlinks: "reject",
|
||||
maxBytes: MAX_CLAW_WORKSPACE_FILE_BYTES,
|
||||
symlinks: "reject",
|
||||
});
|
||||
const createdFiles: PersistedClawWorkspaceFile[] = [];
|
||||
const nowMs = options.nowMs ?? Date.now();
|
||||
|
||||
for (const action of actions) {
|
||||
try {
|
||||
if (!action.source || !action.digest) {
|
||||
throw new ClawWorkspaceWriteError(
|
||||
[
|
||||
diagnostic(
|
||||
action,
|
||||
"workspace_file_plan_invalid",
|
||||
"File action lacks source or digest.",
|
||||
),
|
||||
],
|
||||
createdFiles,
|
||||
);
|
||||
}
|
||||
const sourcePath = resolve(action.source);
|
||||
const targetPath = resolve(action.target);
|
||||
const sourceRelative = containedRelativePath(packageRoot, sourcePath);
|
||||
const targetRelative = containedRelativePath(workspaceRoot, targetPath);
|
||||
if (!sourceRelative || !targetRelative) {
|
||||
throw new ClawWorkspaceWriteError(
|
||||
[
|
||||
diagnostic(
|
||||
action,
|
||||
"workspace_file_path_escape",
|
||||
"Workspace file source and destination must remain inside their owned roots.",
|
||||
),
|
||||
],
|
||||
createdFiles,
|
||||
);
|
||||
}
|
||||
const read = await source.read(sourceRelative, {
|
||||
hardlinks: "reject",
|
||||
maxBytes: MAX_CLAW_WORKSPACE_FILE_BYTES,
|
||||
symlinks: "reject",
|
||||
});
|
||||
if (resolve(read.realPath) !== sourcePath) {
|
||||
throw new ClawWorkspaceWriteError(
|
||||
[
|
||||
diagnostic(
|
||||
action,
|
||||
"workspace_file_path_alias",
|
||||
`Workspace source ${JSON.stringify(action.id)} no longer resolves to the consented file.`,
|
||||
),
|
||||
],
|
||||
createdFiles,
|
||||
);
|
||||
}
|
||||
const digest = contentDigest(read.buffer);
|
||||
if (digest !== action.digest) {
|
||||
throw new ClawWorkspaceWriteError(
|
||||
[
|
||||
diagnostic(
|
||||
action,
|
||||
"workspace_source_changed",
|
||||
`Workspace source for ${JSON.stringify(action.id)} changed after planning.`,
|
||||
),
|
||||
],
|
||||
createdFiles,
|
||||
);
|
||||
}
|
||||
const expectedRecord: PersistedClawWorkspaceFile = {
|
||||
schemaVersion: CLAW_WORKSPACE_FILE_RECORD_SCHEMA_VERSION,
|
||||
agentId: plan.agent.finalId,
|
||||
workspace: workspace.rootReal,
|
||||
path: targetRelative.replaceAll(sep, "/"),
|
||||
sourcePath: sourceRelative.replaceAll(sep, "/"),
|
||||
contentDigest: digest,
|
||||
status: "pending",
|
||||
createdAtMs: nowMs,
|
||||
updatedAtMs: nowMs,
|
||||
};
|
||||
const existingRecord = readWorkspaceFile(
|
||||
expectedRecord.agentId,
|
||||
expectedRecord.path,
|
||||
options,
|
||||
);
|
||||
if (existingRecord && !sameWorkspaceFileOwner(existingRecord, expectedRecord)) {
|
||||
throw new ClawWorkspaceWriteError(
|
||||
[
|
||||
diagnostic(
|
||||
action,
|
||||
"workspace_file_ownership_conflict",
|
||||
`Workspace destination ${JSON.stringify(targetRelative)} is already claimed by different Claw provenance.`,
|
||||
),
|
||||
],
|
||||
createdFiles,
|
||||
);
|
||||
}
|
||||
if (await workspace.exists(targetRelative)) {
|
||||
if (!existingRecord || existingRecord.status === "failed") {
|
||||
throw new ClawWorkspaceWriteError(
|
||||
[
|
||||
diagnostic(
|
||||
action,
|
||||
"workspace_file_collision",
|
||||
`Workspace destination ${JSON.stringify(targetRelative)} already exists.`,
|
||||
),
|
||||
],
|
||||
createdFiles,
|
||||
);
|
||||
}
|
||||
const existingTarget = await workspace.read(targetRelative, {
|
||||
hardlinks: "reject",
|
||||
maxBytes: MAX_CLAW_WORKSPACE_FILE_BYTES,
|
||||
symlinks: "reject",
|
||||
});
|
||||
if (contentDigest(existingTarget.buffer) !== expectedRecord.contentDigest) {
|
||||
throw new ClawWorkspaceWriteError(
|
||||
[
|
||||
diagnostic(
|
||||
action,
|
||||
"workspace_file_drift",
|
||||
`Claw-owned workspace destination ${JSON.stringify(targetRelative)} no longer matches its recorded content.`,
|
||||
),
|
||||
],
|
||||
createdFiles,
|
||||
);
|
||||
}
|
||||
const previousStatus = existingRecord.status;
|
||||
existingRecord.status = "complete";
|
||||
existingRecord.updatedAtMs = nowMs;
|
||||
updateWorkspaceFileStatus(existingRecord, [previousStatus], options);
|
||||
createdFiles.push(existingRecord);
|
||||
continue;
|
||||
}
|
||||
const record = existingRecord ?? expectedRecord;
|
||||
if (existingRecord) {
|
||||
const previousStatus = record.status;
|
||||
record.status = "pending";
|
||||
record.updatedAtMs = nowMs;
|
||||
updateWorkspaceFileStatus(record, [previousStatus], options);
|
||||
} else {
|
||||
persistWorkspaceFile(record, options);
|
||||
}
|
||||
try {
|
||||
await workspace.write(targetRelative, read.buffer, { mkdir: true, overwrite: false });
|
||||
record.status = "complete";
|
||||
updateWorkspaceFileStatus(record, ["pending"], options);
|
||||
createdFiles.push(record);
|
||||
} catch (error) {
|
||||
record.status = "failed";
|
||||
try {
|
||||
updateWorkspaceFileStatus(record, ["pending"], options);
|
||||
} catch {
|
||||
// A pending row intentionally remains as evidence of uncertain owner state.
|
||||
record.status = "pending";
|
||||
}
|
||||
createdFiles.push(record);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ClawWorkspaceWriteError) {
|
||||
throw error;
|
||||
}
|
||||
const code =
|
||||
error instanceof FsSafeError ? `workspace_file_${error.code}` : "workspace_file_io_error";
|
||||
throw new ClawWorkspaceWriteError(
|
||||
[diagnostic(action, code, error instanceof Error ? error.message : String(error))],
|
||||
createdFiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
return createdFiles;
|
||||
}
|
||||
+13
@@ -277,6 +277,18 @@ export interface ClawInstalls {
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export interface ClawWorkspaceFiles {
|
||||
agent_id: string;
|
||||
content_digest: string;
|
||||
created_at_ms: number;
|
||||
schema_version: string;
|
||||
source_path: string;
|
||||
status: string;
|
||||
target_path: string;
|
||||
updated_at_ms: number;
|
||||
workspace: string;
|
||||
}
|
||||
|
||||
export interface ClawhubPromotionClaims {
|
||||
claimed_at_ms: number;
|
||||
ends_at_ms: number;
|
||||
@@ -1378,6 +1390,7 @@ export interface DB {
|
||||
channel_pairing_allow_entries: ChannelPairingAllowEntries;
|
||||
channel_pairing_requests: ChannelPairingRequests;
|
||||
claw_installs: ClawInstalls;
|
||||
claw_workspace_files: ClawWorkspaceFiles;
|
||||
clawhub_promotion_claims: ClawhubPromotionClaims;
|
||||
clawhub_promotions_feed_state: ClawhubPromotionsFeedState;
|
||||
command_log_entries: CommandLogEntries;
|
||||
|
||||
@@ -1953,4 +1953,17 @@ CREATE TABLE IF NOT EXISTS claw_installs (
|
||||
),
|
||||
added_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS claw_workspace_files (
|
||||
agent_id TEXT NOT NULL,
|
||||
target_path TEXT NOT NULL,
|
||||
schema_version TEXT NOT NULL,
|
||||
workspace TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (agent_id, target_path)
|
||||
) STRICT;\n`;
|
||||
|
||||
@@ -1949,3 +1949,16 @@ CREATE TABLE IF NOT EXISTS claw_installs (
|
||||
added_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS claw_workspace_files (
|
||||
agent_id TEXT NOT NULL,
|
||||
target_path TEXT NOT NULL,
|
||||
schema_version TEXT NOT NULL,
|
||||
workspace TEXT NOT NULL,
|
||||
source_path TEXT NOT NULL,
|
||||
content_digest TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (agent_id, target_path)
|
||||
) STRICT;
|
||||
|
||||
Reference in New Issue
Block a user