@axiom-lattice/local-stores 2.0.9 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +57 -0
- package/dist/index.d.mts +33 -4
- package/dist/index.d.ts +33 -4
- package/dist/index.js +515 -39
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +515 -39
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/__tests__/LocalA2AApiKeyStore.test.ts +119 -0
- package/src/__tests__/LocalChannelInstallationStore.test.ts +38 -0
- package/src/__tests__/LocalProjectStore.test.ts +127 -0
- package/src/__tests__/LocalTaskStore.test.ts +284 -2
- package/src/__tests__/LocalTaskWorkItemStore.test.ts +215 -0
- package/src/database.ts +8 -6
- package/src/stores/LocalA2AApiKeyStore.ts +57 -14
- package/src/stores/LocalChannelInstallationStore.ts +1 -9
- package/src/stores/LocalProjectStore.ts +30 -4
- package/src/stores/LocalTaskStore.ts +208 -7
- package/src/stores/LocalTaskWorkItemStore.ts +47 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@axiom-lattice/local-stores",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Local SQLite-based stores for Axiom Lattice framework",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.mjs",
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"@types/sql.js": "^1.4.11",
|
|
26
26
|
"sql.js": "^1.14.1",
|
|
27
27
|
"uuid": "^14.0.1",
|
|
28
|
-
"@axiom-lattice/core": "
|
|
29
|
-
"@axiom-lattice/protocols": "
|
|
28
|
+
"@axiom-lattice/core": "4.0.0",
|
|
29
|
+
"@axiom-lattice/protocols": "4.0.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@types/jest": "^29.5.14",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalA2AApiKeyStore behavior tests
|
|
3
|
+
*
|
|
4
|
+
* Verifies the SQLite store aligns with the new A2A key scope model:
|
|
5
|
+
* projectId required, assistantIds persisted (JSON), workspaceId removed.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
|
|
12
|
+
jest.mock("@axiom-lattice/core", () => ({
|
|
13
|
+
encrypt: (value: string) => `enc:${value}`,
|
|
14
|
+
decrypt: (value: string) => value.replace(/^enc:/, ""),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
import { initDatabase, closeDatabase, type DatabaseWrapper } from "../database";
|
|
18
|
+
import { LocalA2AApiKeyStore } from "../stores/LocalA2AApiKeyStore";
|
|
19
|
+
|
|
20
|
+
const nextDbPath = () =>
|
|
21
|
+
path.join(tmpdir(), `lt-a2a-key-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
22
|
+
|
|
23
|
+
describe("LocalA2AApiKeyStore", () => {
|
|
24
|
+
let store: LocalA2AApiKeyStore;
|
|
25
|
+
let db: DatabaseWrapper;
|
|
26
|
+
|
|
27
|
+
beforeEach(async () => {
|
|
28
|
+
db = await initDatabase({ dbPath: nextDbPath() });
|
|
29
|
+
store = new LocalA2AApiKeyStore(db);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("finds a key by its management id", async () => {
|
|
33
|
+
const record = await store.create({ tenantId: "t1", projectId: "p1" });
|
|
34
|
+
|
|
35
|
+
await expect(store.findById(record.id)).resolves.toMatchObject({
|
|
36
|
+
id: record.id,
|
|
37
|
+
tenantId: "t1",
|
|
38
|
+
});
|
|
39
|
+
await expect(store.findById("missing")).resolves.toBeNull();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("repairs and preserves a legacy row management id on initialization", async () => {
|
|
43
|
+
const now = new Date().toISOString();
|
|
44
|
+
db.prepare(
|
|
45
|
+
`INSERT INTO lt_a2a_api_keys
|
|
46
|
+
(key_value, tenant_id, project_id, label, enabled, created_at, updated_at)
|
|
47
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
48
|
+
).run("enc:a2a_legacy", "t1", "p1", "legacy", 1, now, now);
|
|
49
|
+
|
|
50
|
+
store = new LocalA2AApiKeyStore(db);
|
|
51
|
+
const [legacy] = await store.list({ tenantId: "t1" });
|
|
52
|
+
|
|
53
|
+
expect(legacy.id).toMatch(
|
|
54
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
55
|
+
);
|
|
56
|
+
await expect(store.findById(legacy.id)).resolves.toMatchObject({ id: legacy.id });
|
|
57
|
+
await expect(store.disable(legacy.id)).resolves.toMatchObject({ enabled: false });
|
|
58
|
+
|
|
59
|
+
store = new LocalA2AApiKeyStore(db);
|
|
60
|
+
await expect(store.findById(legacy.id)).resolves.toMatchObject({
|
|
61
|
+
id: legacy.id,
|
|
62
|
+
enabled: false,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("repairs empty and malformed management ids only once", async () => {
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
const insert = db.prepare(
|
|
69
|
+
`INSERT INTO lt_a2a_api_keys
|
|
70
|
+
(id, key_value, tenant_id, project_id, label, enabled, created_at, updated_at)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
72
|
+
);
|
|
73
|
+
insert.run("", "enc:a2a_empty", "t1", "p1", "empty", 1, now, now);
|
|
74
|
+
insert.run("legacy-row-id", "enc:a2a_invalid", "t1", "p1", "invalid", 1, now, now);
|
|
75
|
+
|
|
76
|
+
store = new LocalA2AApiKeyStore(db);
|
|
77
|
+
const repaired = await store.list({ tenantId: "t1" });
|
|
78
|
+
const ids = new Map(repaired.map((record) => [record.label, record.id]));
|
|
79
|
+
|
|
80
|
+
expect(ids.get("empty")).toMatch(
|
|
81
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
82
|
+
);
|
|
83
|
+
expect(ids.get("invalid")).toMatch(
|
|
84
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
store = new LocalA2AApiKeyStore(db);
|
|
88
|
+
const reloaded = await store.list({ tenantId: "t1" });
|
|
89
|
+
expect(new Map(reloaded.map((record) => [record.label, record.id]))).toEqual(ids);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
afterEach(() => {
|
|
93
|
+
closeDatabase();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("create persists projectId and assistantIds, no workspaceId", async () => {
|
|
97
|
+
const record = await store.create({
|
|
98
|
+
tenantId: "t1",
|
|
99
|
+
projectId: "p1",
|
|
100
|
+
assistantIds: ["a1", "a2"],
|
|
101
|
+
label: "l",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
expect(record.projectId).toBe("p1");
|
|
105
|
+
expect(record.assistantIds).toEqual(["a1", "a2"]);
|
|
106
|
+
expect("workspaceId" in record).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("loadIntoMap returns assistantIds and omits workspaceId", async () => {
|
|
110
|
+
await store.create({ tenantId: "t1", projectId: "p1", assistantIds: ["a1"] });
|
|
111
|
+
|
|
112
|
+
const map = await store.loadIntoMap();
|
|
113
|
+
expect(map.size).toBe(1);
|
|
114
|
+
const entry = [...map.values()][0];
|
|
115
|
+
expect(entry.projectId).toBe("p1");
|
|
116
|
+
expect(entry.assistantIds).toEqual(["a1"]);
|
|
117
|
+
expect("workspaceId" in entry).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { tmpdir } from "os";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { closeDatabase, initDatabase, type DatabaseWrapper } from "../database";
|
|
4
|
+
import { LocalChannelInstallationStore } from "../stores/LocalChannelInstallationStore";
|
|
5
|
+
|
|
6
|
+
const nextDbPath = (): string =>
|
|
7
|
+
path.join(tmpdir(), `lt-channel-installation-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
8
|
+
|
|
9
|
+
describe("LocalChannelInstallationStore", () => {
|
|
10
|
+
let db: DatabaseWrapper;
|
|
11
|
+
let store: LocalChannelInstallationStore;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
db = await initDatabase({ dbPath: nextDbPath() });
|
|
15
|
+
store = new LocalChannelInstallationStore(db);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
closeDatabase();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("rejects a duplicate id without changing the original installation", async () => {
|
|
23
|
+
const original = await store.createInstallation("tenant-a", "install-1", {
|
|
24
|
+
channel: "lark",
|
|
25
|
+
name: "Original",
|
|
26
|
+
config: { appId: "app-1", appSecret: "secret-1" },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await expect(
|
|
30
|
+
store.createInstallation("tenant-a", "install-1", {
|
|
31
|
+
channel: "lark",
|
|
32
|
+
name: "Replacement",
|
|
33
|
+
config: { appId: "app-2", appSecret: "secret-2" },
|
|
34
|
+
}),
|
|
35
|
+
).rejects.toThrow();
|
|
36
|
+
await expect(store.getInstallationById("install-1")).resolves.toEqual(original);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalProjectStore project kind tests
|
|
3
|
+
*
|
|
4
|
+
* Projects support an optional `kind` ('business' | 'training' | 'personal').
|
|
5
|
+
* Omitted kind defaults to 'business' for backward compatibility, and
|
|
6
|
+
* getProjectsByWorkspace supports an optional kind filter.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
|
|
10
|
+
import { tmpdir } from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { initDatabase, closeDatabase } from "../database";
|
|
13
|
+
import { LocalProjectStore } from "../stores/LocalProjectStore";
|
|
14
|
+
|
|
15
|
+
// sql.js persists to disk via DatabaseWrapper.save(), so each test needs a
|
|
16
|
+
// unique file path (":memory:" is treated as a plain relative path).
|
|
17
|
+
const nextDbPath = () =>
|
|
18
|
+
path.join(tmpdir(), `lt-project-test-${Date.now()}-${Math.random().toString(36).slice(2)}.db`);
|
|
19
|
+
|
|
20
|
+
describe("LocalProjectStore project kind", () => {
|
|
21
|
+
let store: LocalProjectStore;
|
|
22
|
+
const tenantId = "t1";
|
|
23
|
+
const workspaceId = "ws1";
|
|
24
|
+
|
|
25
|
+
beforeEach(async () => {
|
|
26
|
+
const db = await initDatabase({ dbPath: nextDbPath() });
|
|
27
|
+
store = new LocalProjectStore(db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
closeDatabase();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("defaults kind to 'business' when omitted on create", async () => {
|
|
35
|
+
const project = await store.createProject(tenantId, workspaceId, "p1", {
|
|
36
|
+
name: "Default Kind",
|
|
37
|
+
});
|
|
38
|
+
expect(project.kind).toBe("business");
|
|
39
|
+
|
|
40
|
+
const fetched = await store.getProjectById(tenantId, "p1");
|
|
41
|
+
expect(fetched?.kind).toBe("business");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("round-trips an explicit kind through create and get", async () => {
|
|
45
|
+
const project = await store.createProject(tenantId, workspaceId, "p2", {
|
|
46
|
+
name: "Training Project",
|
|
47
|
+
kind: "training",
|
|
48
|
+
});
|
|
49
|
+
expect(project.kind).toBe("training");
|
|
50
|
+
|
|
51
|
+
const fetched = await store.getProjectById(tenantId, "p2");
|
|
52
|
+
expect(fetched?.kind).toBe("training");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("updates kind via updateProject", async () => {
|
|
56
|
+
await store.createProject(tenantId, workspaceId, "p3", {
|
|
57
|
+
name: "Reclassified",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const updated = await store.updateProject(tenantId, "p3", { kind: "personal" });
|
|
61
|
+
expect(updated?.kind).toBe("personal");
|
|
62
|
+
|
|
63
|
+
const fetched = await store.getProjectById(tenantId, "p3");
|
|
64
|
+
expect(fetched?.kind).toBe("personal");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("filters projects by kind in getProjectsByWorkspace", async () => {
|
|
68
|
+
await store.createProject(tenantId, workspaceId, "p4", { name: "Biz", kind: "business" });
|
|
69
|
+
await store.createProject(tenantId, workspaceId, "p5", { name: "Train", kind: "training" });
|
|
70
|
+
await store.createProject(tenantId, workspaceId, "p6", { name: "Personal", kind: "personal" });
|
|
71
|
+
|
|
72
|
+
const training = await store.getProjectsByWorkspace(tenantId, workspaceId, { kind: "training" });
|
|
73
|
+
expect(training.map((p) => p.id)).toEqual(["p5"]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("returns all projects when kind filter is omitted", async () => {
|
|
77
|
+
await store.createProject(tenantId, workspaceId, "p7", { name: "Biz", kind: "business" });
|
|
78
|
+
await store.createProject(tenantId, workspaceId, "p8", { name: "Train", kind: "training" });
|
|
79
|
+
|
|
80
|
+
const all = await store.getProjectsByWorkspace(tenantId, workspaceId);
|
|
81
|
+
expect(all).toHaveLength(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("multiple training projects per tenant/workspace", () => {
|
|
85
|
+
it("allows multiple training projects in the same tenant/workspace", async () => {
|
|
86
|
+
await store.createProject(tenantId, workspaceId, "t1", {
|
|
87
|
+
name: "Training",
|
|
88
|
+
kind: "training",
|
|
89
|
+
});
|
|
90
|
+
await store.createProject(tenantId, workspaceId, "t2", {
|
|
91
|
+
name: "Training 2",
|
|
92
|
+
kind: "training",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const training = await store.getProjectsByWorkspace(tenantId, workspaceId, {
|
|
96
|
+
kind: "training",
|
|
97
|
+
});
|
|
98
|
+
expect(training.map((p) => p.id).sort()).toEqual(["t1", "t2"]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("allows training projects with the same workspace in different tenants", async () => {
|
|
102
|
+
await store.createProject("tenant-a", workspaceId, "ta", {
|
|
103
|
+
name: "Training",
|
|
104
|
+
kind: "training",
|
|
105
|
+
});
|
|
106
|
+
await store.createProject("tenant-b", workspaceId, "tb", {
|
|
107
|
+
name: "Training",
|
|
108
|
+
kind: "training",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const a = await store.getProjectsByWorkspace("tenant-a", workspaceId, { kind: "training" });
|
|
112
|
+
const b = await store.getProjectsByWorkspace("tenant-b", workspaceId, { kind: "training" });
|
|
113
|
+
expect(a).toHaveLength(1);
|
|
114
|
+
expect(b).toHaveLength(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("allows duplicate non-training kinds in the same tenant/workspace", async () => {
|
|
118
|
+
await store.createProject(tenantId, workspaceId, "b1", { name: "Biz 1", kind: "business" });
|
|
119
|
+
await store.createProject(tenantId, workspaceId, "b2", { name: "Biz 2", kind: "business" });
|
|
120
|
+
await store.createProject(tenantId, workspaceId, "p1", { name: "Per 1", kind: "personal" });
|
|
121
|
+
await store.createProject(tenantId, workspaceId, "p2", { name: "Per 2", kind: "personal" });
|
|
122
|
+
|
|
123
|
+
const all = await store.getProjectsByWorkspace(tenantId, workspaceId);
|
|
124
|
+
expect(all).toHaveLength(4);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
@@ -11,8 +11,17 @@
|
|
|
11
11
|
import { describe, expect, it, beforeEach, afterEach } from "@jest/globals";
|
|
12
12
|
import { tmpdir } from "os";
|
|
13
13
|
import path from "path";
|
|
14
|
-
import { initDatabase, closeDatabase } from "../database";
|
|
14
|
+
import { initDatabase, closeDatabase, type DatabaseWrapper } from "../database";
|
|
15
15
|
import { LocalTaskStore } from "../stores/LocalTaskStore";
|
|
16
|
+
import { LocalTaskWorkItemStore } from "../stores/LocalTaskWorkItemStore";
|
|
17
|
+
import { A2ATaskStoreAdapter } from "../../../gateway/src/services/a2a/A2ATaskStoreAdapter";
|
|
18
|
+
import { createTaskLifecycleService } from "../../../core/src/services/TaskLifecycleService";
|
|
19
|
+
import type { A2ATask } from "@axiom-lattice/protocols";
|
|
20
|
+
|
|
21
|
+
jest.mock("uuid", () => {
|
|
22
|
+
let id = 0;
|
|
23
|
+
return { v4: jest.fn(() => `test-id-${++id}`) };
|
|
24
|
+
});
|
|
16
25
|
|
|
17
26
|
// sql.js persists to disk via DatabaseWrapper.save(), so each test needs a
|
|
18
27
|
// unique file path (":memory:" is treated as a plain relative path).
|
|
@@ -21,9 +30,10 @@ const nextDbPath = () =>
|
|
|
21
30
|
|
|
22
31
|
describe("LocalTaskStore metadata filter", () => {
|
|
23
32
|
let store: LocalTaskStore;
|
|
33
|
+
let db: DatabaseWrapper;
|
|
24
34
|
|
|
25
35
|
beforeEach(async () => {
|
|
26
|
-
|
|
36
|
+
db = await initDatabase({ dbPath: nextDbPath() });
|
|
27
37
|
store = new LocalTaskStore(db);
|
|
28
38
|
});
|
|
29
39
|
|
|
@@ -49,6 +59,54 @@ describe("LocalTaskStore metadata filter", () => {
|
|
|
49
59
|
expect(byBoolean).toHaveLength(1);
|
|
50
60
|
});
|
|
51
61
|
|
|
62
|
+
it("rejects mutation at the maximum canonical task timestamp", async () => {
|
|
63
|
+
const task = await store.create({
|
|
64
|
+
tenantId: "t1",
|
|
65
|
+
ownerType: "user",
|
|
66
|
+
ownerId: "user-1",
|
|
67
|
+
title: "Maximum",
|
|
68
|
+
});
|
|
69
|
+
db.prepare("UPDATE lt_tasks SET updated_at = ? WHERE tenant_id = ? AND id = ?")
|
|
70
|
+
.run("9999-12-31T23:59:59.999Z", "t1", task.id);
|
|
71
|
+
|
|
72
|
+
await expect(store.update("t1", task.id, { title: "Cannot advance" })).resolves.toBeNull();
|
|
73
|
+
expect((await store.getById("t1", task.id))?.title).toBe("Maximum");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("treats context undefined as a no-op and null as an explicit clear", async () => {
|
|
77
|
+
const task = await store.create({
|
|
78
|
+
tenantId: "t1",
|
|
79
|
+
ownerType: "user",
|
|
80
|
+
ownerId: "user-1",
|
|
81
|
+
title: "Context semantics",
|
|
82
|
+
context: { retained: true },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const unchanged = await store.update("t1", task.id, { context: undefined });
|
|
86
|
+
const cleared = await store.update("t1", task.id, { context: null });
|
|
87
|
+
|
|
88
|
+
expect(unchanged?.context).toEqual({ retained: true });
|
|
89
|
+
expect(unchanged?.updatedAt.getTime()).toBe(task.updatedAt.getTime());
|
|
90
|
+
expect(cleared?.context).toBeUndefined();
|
|
91
|
+
expect(cleared!.updatedAt.getTime()).toBeGreaterThan(task.updatedAt.getTime());
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("repairs malformed legacy updated_at text during an ordinary update", async () => {
|
|
95
|
+
const task = await store.create({
|
|
96
|
+
tenantId: "t1",
|
|
97
|
+
ownerType: "user",
|
|
98
|
+
ownerId: "user-1",
|
|
99
|
+
title: "Malformed timestamp",
|
|
100
|
+
});
|
|
101
|
+
db.prepare("UPDATE lt_tasks SET updated_at = ? WHERE tenant_id = ? AND id = ?")
|
|
102
|
+
.run("2026-99-99T00:00:00.000Z", "t1", task.id);
|
|
103
|
+
|
|
104
|
+
const repaired = await store.update("t1", task.id, { title: "Repaired" });
|
|
105
|
+
|
|
106
|
+
expect(repaired?.title).toBe("Repaired");
|
|
107
|
+
expect(repaired?.updatedAt.toISOString()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
108
|
+
});
|
|
109
|
+
|
|
52
110
|
it("still matches string metadata values", async () => {
|
|
53
111
|
await store.create({
|
|
54
112
|
tenantId: "t1",
|
|
@@ -81,4 +139,228 @@ describe("LocalTaskStore metadata filter", () => {
|
|
|
81
139
|
const rows = await store.list({ tenantId: "t1", metadata: { archived: "false" } } as never);
|
|
82
140
|
expect(rows).toHaveLength(1);
|
|
83
141
|
});
|
|
142
|
+
|
|
143
|
+
it("honors a caller-provided task id", async () => {
|
|
144
|
+
const task = await store.create({
|
|
145
|
+
id: "a2a-task-1",
|
|
146
|
+
tenantId: "t1",
|
|
147
|
+
ownerType: "agent",
|
|
148
|
+
ownerId: "a1",
|
|
149
|
+
title: "A2A",
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(task.id).toBe("a2a-task-1");
|
|
153
|
+
expect(await store.getById("t1", "a2a-task-1")).not.toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("conditionally updates through independent store instances", async () => {
|
|
157
|
+
const secondStore = new LocalTaskStore(db);
|
|
158
|
+
await store.create({
|
|
159
|
+
id: "task-canceled",
|
|
160
|
+
tenantId: "t1",
|
|
161
|
+
ownerType: "agent",
|
|
162
|
+
ownerId: "a1",
|
|
163
|
+
title: "Canceled",
|
|
164
|
+
status: "cancelled",
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const result = await secondStore.updateIfStatusNotIn(
|
|
168
|
+
"t1",
|
|
169
|
+
"task-canceled",
|
|
170
|
+
{ status: "completed" },
|
|
171
|
+
["cancelled"],
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(result).toBeNull();
|
|
175
|
+
expect((await store.getById("t1", "task-canceled"))?.status).toBe("cancelled");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("atomically updates only from an expected status", async () => {
|
|
179
|
+
const secondStore = new LocalTaskStore(db);
|
|
180
|
+
await store.create({
|
|
181
|
+
id: "task-cas", tenantId: "t1", ownerType: "agent", ownerId: "a1",
|
|
182
|
+
title: "Working", status: "in_progress",
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const first = await store.updateIfStatusIn(
|
|
186
|
+
"t1", "task-cas", { status: "completed", result: "first" }, ["in_progress"],
|
|
187
|
+
);
|
|
188
|
+
const second = await secondStore.updateIfStatusIn(
|
|
189
|
+
"t1", "task-cas", { status: "cancelled", result: "second" }, ["in_progress"],
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(first?.status).toBe("completed");
|
|
193
|
+
expect(second).toBeNull();
|
|
194
|
+
expect((await store.getById("t1", "task-cas"))?.result).toBe("first");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns null for an unexpected status and a missing task", async () => {
|
|
198
|
+
await store.create({
|
|
199
|
+
id: "task-pending", tenantId: "t1", ownerType: "agent", ownerId: "a1",
|
|
200
|
+
title: "Pending", status: "pending",
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await expect(store.updateIfStatusIn(
|
|
204
|
+
"t1", "task-pending", { status: "completed" }, ["in_progress", "interrupted"],
|
|
205
|
+
)).resolves.toBeNull();
|
|
206
|
+
await expect(store.updateIfStatusIn(
|
|
207
|
+
"t1", "missing", { status: "completed" }, ["in_progress"],
|
|
208
|
+
)).resolves.toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("atomically compares status and the normalized updatedAt snapshot", async () => {
|
|
212
|
+
const secondStore = new LocalTaskStore(db);
|
|
213
|
+
const created = await store.create({
|
|
214
|
+
id: "task-snapshot", tenantId: "t1", ownerType: "agent", ownerId: "a1",
|
|
215
|
+
title: "Working", status: "in_progress",
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const [first, second] = await Promise.all([
|
|
219
|
+
store.updateIfStatusAndUpdatedAt(
|
|
220
|
+
"t1", created.id, { context: { winner: "first" } }, ["in_progress"], created.updatedAt.toISOString(),
|
|
221
|
+
),
|
|
222
|
+
secondStore.updateIfStatusAndUpdatedAt(
|
|
223
|
+
"t1", created.id, { context: { winner: "second" } }, ["in_progress"], created.updatedAt,
|
|
224
|
+
),
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
expect(first?.context).toEqual({ winner: "first" });
|
|
228
|
+
expect(first!.updatedAt.getTime()).toBeGreaterThan(created.updatedAt.getTime());
|
|
229
|
+
expect(second).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("rejects a stale snapshot after a same-status description update", async () => {
|
|
233
|
+
const created = await store.create({
|
|
234
|
+
id: "task-stale-snapshot", tenantId: "t1", ownerType: "agent", ownerId: "a1",
|
|
235
|
+
title: "Working", status: "in_progress",
|
|
236
|
+
});
|
|
237
|
+
await store.update("t1", created.id, { description: "changed" });
|
|
238
|
+
|
|
239
|
+
await expect(store.updateIfStatusAndUpdatedAt(
|
|
240
|
+
"t1", created.id, { status: "completed" }, ["in_progress"], created.updatedAt,
|
|
241
|
+
)).resolves.toBeNull();
|
|
242
|
+
expect((await store.getById("t1", created.id))?.status).toBe("in_progress");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("atomically matches both child and parent snapshots", async () => {
|
|
246
|
+
const parent = await store.create({
|
|
247
|
+
id: "parent-snapshot", tenantId: "t1", ownerType: "agent", ownerId: "a1", title: "Parent",
|
|
248
|
+
});
|
|
249
|
+
const child = await store.create({
|
|
250
|
+
id: "child-snapshot", tenantId: "t1", ownerType: "agent", ownerId: "a1", title: "Child",
|
|
251
|
+
status: "in_progress", parentId: parent.id,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const updated = await store.updateIfStatusUpdatedAtAndParentUpdatedAt(
|
|
255
|
+
"t1", child.id, { status: "completed" }, ["in_progress"], child.updatedAt,
|
|
256
|
+
parent.id, parent.updatedAt,
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
expect(updated?.status).toBe("completed");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it("does not update a child when its parent snapshot changed or is missing", async () => {
|
|
263
|
+
const parent = await store.create({
|
|
264
|
+
id: "parent-stale", tenantId: "t1", ownerType: "agent", ownerId: "a1", title: "Parent",
|
|
265
|
+
});
|
|
266
|
+
const staleChild = await store.create({
|
|
267
|
+
id: "child-stale-parent", tenantId: "t1", ownerType: "agent", ownerId: "a1", title: "Child",
|
|
268
|
+
status: "in_progress", parentId: parent.id,
|
|
269
|
+
});
|
|
270
|
+
await store.update("t1", parent.id, { description: "reconciled" });
|
|
271
|
+
|
|
272
|
+
await expect(store.updateIfStatusUpdatedAtAndParentUpdatedAt(
|
|
273
|
+
"t1", staleChild.id, { status: "completed" }, ["in_progress"], staleChild.updatedAt,
|
|
274
|
+
parent.id, parent.updatedAt,
|
|
275
|
+
)).resolves.toBeNull();
|
|
276
|
+
expect((await store.getById("t1", staleChild.id))?.status).toBe("in_progress");
|
|
277
|
+
|
|
278
|
+
const missingChild = await store.create({
|
|
279
|
+
id: "child-missing-parent", tenantId: "t1", ownerType: "agent", ownerId: "a1", title: "Child",
|
|
280
|
+
status: "in_progress", parentId: parent.id,
|
|
281
|
+
});
|
|
282
|
+
const currentParent = await store.getById("t1", parent.id);
|
|
283
|
+
await store.delete("t1", parent.id);
|
|
284
|
+
|
|
285
|
+
await expect(store.updateIfStatusUpdatedAtAndParentUpdatedAt(
|
|
286
|
+
"t1", missingChild.id, { status: "completed" }, ["in_progress"], missingChild.updatedAt,
|
|
287
|
+
parent.id, currentParent!.updatedAt,
|
|
288
|
+
)).resolves.toBeNull();
|
|
289
|
+
expect((await store.getById("t1", missingChild.id))?.status).toBe("in_progress");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("stores nullable lifecycle output clearing as SQL NULL", async () => {
|
|
293
|
+
const created = await store.create({
|
|
294
|
+
id: "task-clear-output", tenantId: "t1", ownerType: "agent", ownerId: "a1",
|
|
295
|
+
title: "Failed", status: "failed", result: "partial", failureReason: "boom",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const updated = await store.updateIfStatusAndUpdatedAt(
|
|
299
|
+
"t1",
|
|
300
|
+
created.id,
|
|
301
|
+
{ result: null, failureReason: null },
|
|
302
|
+
["failed"],
|
|
303
|
+
created.updatedAt,
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
expect(updated?.result).toBeUndefined();
|
|
307
|
+
expect(updated?.failureReason).toBeUndefined();
|
|
308
|
+
const row = db.prepare("SELECT result, failure_reason FROM lt_tasks WHERE tenant_id = ? AND id = ?")
|
|
309
|
+
.get("t1", created.id) as { result: string | null; failure_reason: string | null };
|
|
310
|
+
expect(row).toEqual({ result: null, failure_reason: null });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("removes a completion audit marker when it is the only task context", async () => {
|
|
314
|
+
const workItemStore = new LocalTaskWorkItemStore(db);
|
|
315
|
+
const lifecycle = createTaskLifecycleService({ taskStore: store, workItemStore });
|
|
316
|
+
await store.create({
|
|
317
|
+
id: "task-clean-audit", tenantId: "t1", ownerType: "agent", ownerId: "a1",
|
|
318
|
+
title: "Complete", status: "in_progress",
|
|
319
|
+
description: [
|
|
320
|
+
"## Belief State", "", "| Belief Key | Probability | Target | Basis |",
|
|
321
|
+
"|---|---:|---:|---|", "| `done` | 50% | 90% | pending |",
|
|
322
|
+
].join("\n"),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
const completed = await lifecycle.completeAgentTask({
|
|
326
|
+
tenantId: "t1", taskId: "task-clean-audit", result: "done",
|
|
327
|
+
beliefImpact: [{ key: "done", after: 95, basis: "verified" }], actor: "agent:a1",
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(completed.success).toBe(true);
|
|
331
|
+
expect((await store.getById("t1", "task-clean-audit"))?.context).toBeUndefined();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("keeps one A2A id without externally resuming canonical interruption state", async () => {
|
|
335
|
+
const workItemStore = new LocalTaskWorkItemStore(db);
|
|
336
|
+
const lifecycle = createTaskLifecycleService({ taskStore: store, workItemStore });
|
|
337
|
+
const adapter = new A2ATaskStoreAdapter({
|
|
338
|
+
taskStore: store,
|
|
339
|
+
workItemStore,
|
|
340
|
+
lifecycle,
|
|
341
|
+
scope: { tenantId: "t1", projectId: "p1", assistantId: "a1" },
|
|
342
|
+
});
|
|
343
|
+
const task = (status: A2ATask["status"]): A2ATask => ({
|
|
344
|
+
id: "a2a-single-id",
|
|
345
|
+
contextId: "ctx-1",
|
|
346
|
+
kind: "task",
|
|
347
|
+
status,
|
|
348
|
+
history: [{
|
|
349
|
+
kind: "message",
|
|
350
|
+
messageId: "m1",
|
|
351
|
+
role: "user",
|
|
352
|
+
parts: [{ kind: "text", text: "start" }],
|
|
353
|
+
}],
|
|
354
|
+
metadata: { threadId: "thread-1" },
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
await adapter.save(task({ state: "working" }));
|
|
358
|
+
expect((await adapter.load("a2a-single-id"))?.id).toBe("a2a-single-id");
|
|
359
|
+
await adapter.save(task({ state: "input-required" }));
|
|
360
|
+
expect((await adapter.load("a2a-single-id"))?.status.state).toBe("input-required");
|
|
361
|
+
await adapter.save(task({ state: "working" }));
|
|
362
|
+
|
|
363
|
+
expect((await adapter.load("a2a-single-id"))?.status.state).toBe("input-required");
|
|
364
|
+
expect((await store.list({ tenantId: "t1" }))).toHaveLength(1);
|
|
365
|
+
});
|
|
84
366
|
});
|