Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions openclaw/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,14 +1146,15 @@ export default definePluginEntry({
const ts = new Date().toISOString();
const safe = goalsTable.replace(/[^A-Za-z0-9_]/g, "");
await dl.query(
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${crypto.randomUUID()}', ` +
`'${sqlStr(goalId)}', ` +
`'${sqlStr(owner)}', ` +
`'opened', ` +
`E'${sqlStr(params.text)}', ` +
`1, ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(ts)}', ` +
`'openclaw', ` +
`''` +
`)`
Expand Down Expand Up @@ -1198,13 +1199,14 @@ export default definePluginEntry({
const ts = new Date().toISOString();
const safe = kpisTable.replace(/[^A-Za-z0-9_]/g, "");
await dl.query(
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${crypto.randomUUID()}', ` +
`'${sqlStr(params.goal_id)}', ` +
`'${sqlStr(params.kpi_id)}', ` +
`E'${sqlStr(content)}', ` +
`1, ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(ts)}', ` +
`'openclaw', ` +
`''` +
`)`
Expand Down
21 changes: 15 additions & 6 deletions src/commands/goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,14 +109,15 @@ async function goalAdd(text: string, agent: string = "manual"): Promise<void> {
const goalId = randomUUID();
const ts = new Date().toISOString();
await query(
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${randomUUID()}', ` +
`'${sqlStr(goalId)}', ` +
`'${sqlStr(cfg.userName)}', ` +
`'opened', ` +
`E'${sqlStr(text)}', ` +
`1, ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(agent)}', ` +
`''` +
`)`
Expand Down Expand Up @@ -184,11 +185,15 @@ async function goalProgress(goalId: string, status: string): Promise<void> {
}
const cfg = loadConfig();
if (!cfg) { process.stderr.write("not logged in\n"); process.exit(1); }
const { query } = loadApiOrDie(cfg.goalsTableName);
const { api, query } = loadApiOrDie(cfg.goalsTableName);
// Heal the schema before the UPDATE: an upgraded workspace's preexisting
// table may lack the `updated_at` column, and this path (unlike `goal add`)
// is the only thing that runs before the write.
await api.ensureGoalsTable(cfg.goalsTableName);
const safe = sqlIdent(cfg.goalsTableName);
const ts = new Date().toISOString();
await query(
`UPDATE "${safe}" SET status = '${sqlStr(status)}', created_at = '${sqlStr(ts)}' WHERE goal_id = '${sqlStr(goalId)}'`
`UPDATE "${safe}" SET status = '${sqlStr(status)}', updated_at = '${sqlStr(ts)}' WHERE goal_id = '${sqlStr(goalId)}'`
);
process.stdout.write(`${goalId} -> ${status}\n`);
}
Expand All @@ -215,13 +220,14 @@ async function kpiAdd(args: string[]): Promise<void> {
const content = `${name}\n\n- target: ${target}\n- current: 0\n- unit: ${unit}`;
const ts = new Date().toISOString();
await query(
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${randomUUID()}', ` +
`'${sqlStr(goalId)}', ` +
`'${sqlStr(kpiId)}', ` +
`E'${sqlStr(content)}', ` +
`1, ` +
`'${sqlStr(ts)}', ` +
`'${sqlStr(ts)}', ` +
`'manual', ` +
`''` +
`)`
Expand Down Expand Up @@ -262,7 +268,10 @@ async function kpiBump(goalId: string, kpiId: string, deltaStr: string): Promise
}
const cfg = loadConfig();
if (!cfg) { process.stderr.write("not logged in\n"); process.exit(1); }
const { query } = loadApiOrDie(cfg.kpisTableName);
const { api, query } = loadApiOrDie(cfg.kpisTableName);
// Heal the schema before the UPDATE — same reason as goalProgress: a
// preexisting KPIs table may not yet have the `updated_at` column.
await api.ensureKpisTable(cfg.kpisTableName);
const safe = sqlIdent(cfg.kpisTableName);
// Read current content
const rows = await query(
Expand All @@ -284,7 +293,7 @@ async function kpiBump(goalId: string, kpiId: string, deltaStr: string): Promise
}
const ts = new Date().toISOString();
await query(
`UPDATE "${safe}" SET content = E'${sqlStr(newContent)}', created_at = '${sqlStr(ts)}' WHERE goal_id = '${sqlStr(goalId)}' AND kpi_id = '${sqlStr(kpiId)}'`
`UPDATE "${safe}" SET content = E'${sqlStr(newContent)}', updated_at = '${sqlStr(ts)}' WHERE goal_id = '${sqlStr(goalId)}' AND kpi_id = '${sqlStr(kpiId)}'`
);
process.stdout.write(`${goalId}/${kpiId} +${delta}\n`);
}
Expand Down
2 changes: 2 additions & 0 deletions src/deeplake-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export const GOALS_COLUMNS: readonly ColumnDef[] = Object.freeze([
{ name: "content", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "version", sql: "BIGINT NOT NULL DEFAULT 1" },
{ name: "created_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "updated_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "agent", sql: "TEXT NOT NULL DEFAULT 'manual'" },
{ name: "plugin_version", sql: "TEXT NOT NULL DEFAULT ''" },
]);
Expand Down Expand Up @@ -168,6 +169,7 @@ export const KPIS_COLUMNS: readonly ColumnDef[] = Object.freeze([
{ name: "content", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "version", sql: "BIGINT NOT NULL DEFAULT 1" },
{ name: "created_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "updated_at", sql: "TEXT NOT NULL DEFAULT ''" },
{ name: "agent", sql: "TEXT NOT NULL DEFAULT 'manual'" },
{ name: "plugin_version", sql: "TEXT NOT NULL DEFAULT ''" },
]);
Expand Down
29 changes: 21 additions & 8 deletions src/shell/deeplake-fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,30 +486,37 @@ export class DeeplakeFs implements IFileSystem {
if (!this.goalsTable) throw new Error("goalsTable not configured");
const parts = decomposeGoalPath(r.path);
const safe = this.goalsTable;
const ts = r.lastUpdateDate ?? r.creationDate ?? new Date().toISOString();
const now = new Date().toISOString();
const createdAt = r.creationDate ?? now;
const updatedAt = r.lastUpdateDate ?? createdAt;
const existing = await this.client.query(
`SELECT id FROM "${safe}" WHERE goal_id = '${esc(parts.goal_id)}' LIMIT 1`
);
if (existing.length > 0) {
// Preserve created_at — a status transition or content edit must
// not reset the goal's creation timestamp (it drives created_at
// DESC ordering in the listing and bootstrap). Record the edit
// time in updated_at instead.
await this.client.query(
`UPDATE "${safe}" SET ` +
`owner = '${esc(parts.owner)}', ` +
`status = '${esc(parts.status)}', ` +
`content = E'${esc(r.contentText)}', ` +
`created_at = '${esc(ts)}' ` +
`updated_at = '${esc(updatedAt)}' ` +
`WHERE goal_id = '${esc(parts.goal_id)}'`
);
} else {
const id = randomUUID();
await this.client.query(
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, owner, status, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${id}', ` +
`'${esc(parts.goal_id)}', ` +
`'${esc(parts.owner)}', ` +
`'${esc(parts.status)}', ` +
`E'${esc(r.contentText)}', ` +
`1, ` +
`'${esc(ts)}', ` +
`'${esc(createdAt)}', ` +
`'${esc(updatedAt)}', ` +
`'manual', ` +
`''` +
`)`
Expand All @@ -528,28 +535,34 @@ export class DeeplakeFs implements IFileSystem {
if (!this.kpisTable) throw new Error("kpisTable not configured");
const parts = decomposeKpiPath(r.path);
const safe = this.kpisTable;
const ts = r.lastUpdateDate ?? r.creationDate ?? new Date().toISOString();
const now = new Date().toISOString();
const createdAt = r.creationDate ?? now;
const updatedAt = r.lastUpdateDate ?? createdAt;
const existing = await this.client.query(
`SELECT id FROM "${safe}" ` +
`WHERE goal_id = '${esc(parts.goal_id)}' AND kpi_id = '${esc(parts.kpi_id)}' LIMIT 1`
);
if (existing.length > 0) {
// Preserve created_at — KPI progress edits keep their original
// creation time so the KPI list stays in stable creation order
// (created_at ASC). Edit time goes to updated_at.
await this.client.query(
`UPDATE "${safe}" SET ` +
`content = E'${esc(r.contentText)}', ` +
`created_at = '${esc(ts)}' ` +
`updated_at = '${esc(updatedAt)}' ` +
`WHERE goal_id = '${esc(parts.goal_id)}' AND kpi_id = '${esc(parts.kpi_id)}'`
);
} else {
const id = randomUUID();
await this.client.query(
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, agent, plugin_version) VALUES (` +
`INSERT INTO "${safe}" (id, goal_id, kpi_id, content, version, created_at, updated_at, agent, plugin_version) VALUES (` +
`'${id}', ` +
`'${esc(parts.goal_id)}', ` +
`'${esc(parts.kpi_id)}', ` +
`E'${esc(r.contentText)}', ` +
`1, ` +
`'${esc(ts)}', ` +
`'${esc(createdAt)}', ` +
`'${esc(updatedAt)}', ` +
`'manual', ` +
`''` +
`)`
Expand Down
8 changes: 6 additions & 2 deletions tests/claude-code/cli-goal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ describe("runGoalCommand — add", () => {
expect(ensureGoalsTableMock).toHaveBeenCalledExactlyOnceWith("hivemind_goals_test");
expect(queryMock).toHaveBeenCalledTimes(1);
const sql = queryMock.mock.calls[0][0] as string;
expect(sql).toMatch(/^INSERT INTO "hivemind_goals_test" \(id, goal_id, owner, status, content, version, created_at, agent, plugin_version\)/);
expect(sql).toMatch(/^INSERT INTO "hivemind_goals_test" \(id, goal_id, owner, status, content, version, created_at, updated_at, agent, plugin_version\)/);
expect(sql).toContain("'opened'");
expect(sql).toContain("'alice@activeloop.ai'");
expect(sql).toContain("'manual'");
Expand Down Expand Up @@ -354,6 +354,8 @@ describe("runGoalCommand — get", () => {
describe("runGoalCommand — done & progress", () => {
it("`done` UPDATEs status=closed by goal_id", async () => {
await runGoalCommand(["done", "11111111-2222-3333-4444-555555555555"]);
// Heals the schema first so a preexisting table without `updated_at` can't fail.
expect(ensureGoalsTableMock).toHaveBeenCalledExactlyOnceWith("hivemind_goals_test");
expect(queryMock).toHaveBeenCalledTimes(1);
const sql = queryMock.mock.calls[0][0] as string;
expect(sql).toMatch(/^UPDATE "hivemind_goals_test" SET status = 'closed'/);
Expand Down Expand Up @@ -395,7 +397,7 @@ describe("runKpiCommand — add", () => {
expect(ensureKpisTableMock).toHaveBeenCalledExactlyOnceWith("hivemind_kpis_test");
expect(queryMock).toHaveBeenCalledTimes(1);
const sql = queryMock.mock.calls[0][0] as string;
expect(sql).toMatch(/^INSERT INTO "hivemind_kpis_test" \(id, goal_id, kpi_id, content, version, created_at, agent, plugin_version\)/);
expect(sql).toMatch(/^INSERT INTO "hivemind_kpis_test" \(id, goal_id, kpi_id, content, version, created_at, updated_at, agent, plugin_version\)/);
expect(sql).toContain("'g-uuid'");
expect(sql).toContain("'k-prs'");
expect(sql).toContain("'manual'");
Expand Down Expand Up @@ -475,6 +477,8 @@ describe("runKpiCommand — bump", () => {
// UPDATE — empty result
.mockResolvedValueOnce([]);
await runKpiCommand(["bump", "g-uuid", "k-prs", "1"]);
// Heals the schema first so a preexisting table without `updated_at` can't fail.
expect(ensureKpisTableMock).toHaveBeenCalledExactlyOnceWith("hivemind_kpis_test");
expect(queryMock).toHaveBeenCalledTimes(2);
const select = queryMock.mock.calls[0][0] as string;
expect(select).toMatch(/^SELECT content FROM "hivemind_kpis_test"/);
Expand Down
63 changes: 63 additions & 0 deletions tests/claude-code/deeplake-fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,69 @@ describe("DeeplakeFs goal/kpi namespace isolation", () => {
// No dedicated table → the memory-table copy is the only one, keep it.
expect(await fs.readdir("/goal/alice/opened")).toContain("legacy.md");
});

it("preserves created_at on a status transition, recording the move in updated_at", async () => {
const sql: string[] = [];
const client = {
applyStorageCreds: vi.fn().mockResolvedValue(undefined),
ensureTable: vi.fn().mockResolvedValue(undefined),
ensureGoalsTable: vi.fn().mockResolvedValue(undefined),
ensureKpisTable: vi.fn().mockResolvedValue(undefined),
listTables: vi.fn().mockResolvedValue(["memory", "goals", "kpis"]),
query: vi.fn().mockImplementation(async (q: string) => {
sql.push(q);
if (q.includes("SELECT path, size_bytes, mime_type")) return [];
if (q.includes("SELECT goal_id, owner, status, content")) {
return [{ goal_id: "g1", owner: "alice", status: "opened", content: "do it", created_at: "2026-01-01" }];
}
// upsertGoalRow existence check → row exists, take the UPDATE branch.
if (q.startsWith("SELECT id FROM")) return [{ id: "row-1" }];
return [];
}),
};
const fs = await DeeplakeFs.create(client as never, "memory", "/", "sessions", {
goalsTable: "goals",
kpisTable: "kpis",
});

await fs.mv("/goal/alice/opened/g1.md", "/goal/alice/closed/g1.md");

const update = sql.find(q => q.includes("UPDATE") && q.includes("status = 'closed'"));
expect(update).toBeDefined();
expect(update).toContain("updated_at ="); // edit time recorded here
expect(update).not.toContain("created_at ="); // creation time left untouched
});

it("keeps created_at and updated_at independent on a fresh goal INSERT", async () => {
const sql: string[] = [];
const client = {
applyStorageCreds: vi.fn().mockResolvedValue(undefined),
ensureTable: vi.fn().mockResolvedValue(undefined),
ensureGoalsTable: vi.fn().mockResolvedValue(undefined),
ensureKpisTable: vi.fn().mockResolvedValue(undefined),
listTables: vi.fn().mockResolvedValue(["memory", "goals", "kpis"]),
query: vi.fn().mockImplementation(async (q: string) => {
sql.push(q);
// upsertGoalRow existence check → no row, take the INSERT branch.
return [];
}),
};
const fs = await DeeplakeFs.create(client as never, "memory", "/", "sessions", {
goalsTable: "goals",
kpisTable: "kpis",
});

await fs.writeFileWithMeta("/goal/alice/opened/g2.md", "do it later", {
creationDate: "2026-01-01T00:00:00.000Z",
lastUpdateDate: "2026-02-02T00:00:00.000Z",
});
await fs.flush();

const insert = sql.find(q => q.startsWith("INSERT") && q.includes('"goals"'));
expect(insert).toBeDefined();
expect(insert).toContain("'2026-01-01T00:00:00.000Z'"); // created_at
expect(insert).toContain("'2026-02-02T00:00:00.000Z'"); // updated_at — distinct, not clobbered
});
});

describe("guessMime", () => {
Expand Down
6 changes: 3 additions & 3 deletions tests/openclaw/hivemind-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,8 +348,8 @@ describe("hivemind_goal_add (Path C — write-side via registered tool)", () =>
const sql = goalInserts[0][0] as string;

// shape — must include the per-row uuid + goal_id + owner + status + content +
// version + created_at + agent + plugin_version columns in that order
expect(sql).toMatch(/INSERT INTO "hivemind_goals_test" \(id, goal_id, owner, status, content, version, created_at, agent, plugin_version\)/);
// version + created_at + updated_at + agent + plugin_version columns in that order
expect(sql).toMatch(/INSERT INTO "hivemind_goals_test" \(id, goal_id, owner, status, content, version, created_at, updated_at, agent, plugin_version\)/);
// owner is the userName from the config mock
expect(sql).toContain("'alice'");
// status is hardcoded to 'opened' for new goals
Expand Down Expand Up @@ -415,7 +415,7 @@ describe("hivemind_kpi_add (Path C — write-side via registered tool)", () => {
expect(kpiInserts).toHaveLength(1);
const sql = kpiInserts[0][0] as string;

expect(sql).toMatch(/INSERT INTO "hivemind_kpis_test" \(id, goal_id, kpi_id, content, version, created_at, agent, plugin_version\)/);
expect(sql).toMatch(/INSERT INTO "hivemind_kpis_test" \(id, goal_id, kpi_id, content, version, created_at, updated_at, agent, plugin_version\)/);
expect(sql).toContain("'11111111-2222-3333-4444-555555555555'");
expect(sql).toContain("'k-prs'");
expect(sql).toContain("'openclaw'");
Expand Down
Loading