@stigmer/server 3.19.0 → 3.20.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/dist/authorization/model/index.d.ts +1 -1
- package/dist/authorization/model/index.d.ts.map +1 -1
- package/dist/authorization/model/index.js +0 -2
- package/dist/authorization/model/index.js.map +1 -1
- package/dist/authorization/model/organization.d.ts.map +1 -1
- package/dist/authorization/model/organization.js +0 -1
- package/dist/authorization/model/organization.js.map +1 -1
- package/dist/boot/compose.d.ts.map +1 -1
- package/dist/boot/compose.js +2 -15
- package/dist/boot/compose.js.map +1 -1
- package/dist/boot/inprocess.d.ts +2 -13
- package/dist/boot/inprocess.d.ts.map +1 -1
- package/dist/boot/inprocess.js +6 -16
- package/dist/boot/inprocess.js.map +1 -1
- package/dist/domain/workflow/registry/data/task-kind-registry.json +0 -1
- package/dist/pipeline/interceptors/auth.d.ts +1 -1
- package/dist/pipeline/interceptors/auth.js +1 -1
- package/dist/query/search/registry.d.ts +3 -5
- package/dist/query/search/registry.d.ts.map +1 -1
- package/dist/query/search/registry.js +3 -7
- package/dist/query/search/registry.js.map +1 -1
- package/dist/store/postgres/migrations.d.ts +3 -1
- package/dist/store/postgres/migrations.d.ts.map +1 -1
- package/dist/store/postgres/migrations.js +19 -1
- package/dist/store/postgres/migrations.js.map +1 -1
- package/dist/store/sqlite/migrations.d.ts +3 -1
- package/dist/store/sqlite/migrations.d.ts.map +1 -1
- package/dist/store/sqlite/migrations.js +22 -1
- package/dist/store/sqlite/migrations.js.map +1 -1
- package/package.json +6 -6
- package/src/authorization/__tests__/derived-tuples.test.ts +1 -1
- package/src/authorization/__tests__/fixtures/fga/org-admin-owner-inheritance.fga.yaml +6 -6
- package/src/authorization/__tests__/wire-permissions.test.ts +0 -1
- package/src/authorization/model/__tests__/registry.test.ts +0 -9
- package/src/authorization/model/index.ts +1 -3
- package/src/authorization/model/organization.ts +0 -1
- package/src/boot/compose.ts +2 -15
- package/src/boot/inprocess.ts +8 -29
- package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +0 -1
- package/src/domain/workflow/registry/data/task-kind-registry.json +0 -1
- package/src/pipeline/__tests__/router-transport.test.ts +1 -1
- package/src/pipeline/interceptors/auth.ts +1 -1
- package/src/query/__tests__/query-services.test.ts +16 -18
- package/src/query/search/__tests__/criteria.test.ts +3 -6
- package/src/query/search/__tests__/query-store.test.ts +21 -21
- package/src/query/search/registry.ts +3 -7
- package/src/store/postgres/__tests__/migrations.test.ts +23 -4
- package/src/store/postgres/migrations.ts +20 -1
- package/src/store/sqlite/__tests__/migrations.test.ts +26 -2
- package/src/store/sqlite/migrations.ts +23 -1
- package/dist/authorization/model/project.d.ts +0 -2
- package/dist/authorization/model/project.d.ts.map +0 -1
- package/dist/authorization/model/project.js +0 -32
- package/dist/authorization/model/project.js.map +0 -1
- package/dist/domain/project/controller.d.ts +0 -48
- package/dist/domain/project/controller.d.ts.map +0 -1
- package/dist/domain/project/controller.js +0 -192
- package/dist/domain/project/controller.js.map +0 -1
- package/dist/domain/project/reconcile.d.ts +0 -77
- package/dist/domain/project/reconcile.d.ts.map +0 -1
- package/dist/domain/project/reconcile.js +0 -179
- package/dist/domain/project/reconcile.js.map +0 -1
- package/dist/domain/project/search-extractor.d.ts +0 -3
- package/dist/domain/project/search-extractor.d.ts.map +0 -1
- package/dist/domain/project/search-extractor.js +0 -43
- package/dist/domain/project/search-extractor.js.map +0 -1
- package/src/authorization/model/project.ts +0 -47
- package/src/domain/project/__tests__/project.composed.test.ts +0 -381
- package/src/domain/project/__tests__/reconcile.test.ts +0 -447
- package/src/domain/project/controller.ts +0 -387
- package/src/domain/project/reconcile.ts +0 -296
- package/src/domain/project/search-extractor.ts +0 -71
|
@@ -1,447 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Pins the project reconciler against Go's reconcile/service_test.go
|
|
3
|
-
* case-for-case (set difference, dry-run, prune-off, orphan resolution and
|
|
4
|
-
* deletion with continue-on-failure) over a REAL sqlite store, plus the
|
|
5
|
-
* toProtoSummary mapping (updated ALWAYS empty; an all-empty result is
|
|
6
|
-
* still a present message) and the search extractor's field mapping.
|
|
7
|
-
*
|
|
8
|
-
* Go's nil-deleter stub arm and nil-options arm are deliberately absent —
|
|
9
|
-
* both unreachable here (plan-gate decisions 2/3, tasks/T01_0_plan.md).
|
|
10
|
-
*/
|
|
11
|
-
import { mkdtempSync, rmSync } from "node:fs";
|
|
12
|
-
import { tmpdir } from "node:os";
|
|
13
|
-
import path from "node:path";
|
|
14
|
-
|
|
15
|
-
import { create } from "@bufbuild/protobuf";
|
|
16
|
-
import { timestampFromDate } from "@bufbuild/protobuf/wkt";
|
|
17
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
18
|
-
|
|
19
|
-
import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
|
|
20
|
-
import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
|
|
21
|
-
import { SkillSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
|
|
22
|
-
import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
|
|
23
|
-
import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
|
|
24
|
-
import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
|
|
25
|
-
import { ApiResourceReferenceSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
26
|
-
import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
|
|
27
|
-
import { ProjectSchema } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/api_pb";
|
|
28
|
-
|
|
29
|
-
import { createLogger } from "../../../boot/logger.js";
|
|
30
|
-
import { SqliteStore } from "../../../store/sqlite/store.js";
|
|
31
|
-
import {
|
|
32
|
-
DEFAULT_RECONCILIATION_OPTIONS,
|
|
33
|
-
newReconciliationService,
|
|
34
|
-
referenceKey,
|
|
35
|
-
toProtoSummary,
|
|
36
|
-
} from "../reconcile.js";
|
|
37
|
-
import type { OrphanDeleter, ReconciliationService } from "../reconcile.js";
|
|
38
|
-
import { projectSearchExtractor } from "../search-extractor.js";
|
|
39
|
-
|
|
40
|
-
const silentLogger = createLogger({ level: "error", pretty: false, write: () => {} });
|
|
41
|
-
|
|
42
|
-
/** Records delete calls; throws for kinds listed in failKinds (Go's fake clients). */
|
|
43
|
-
class RecordingDeleter implements OrphanDeleter {
|
|
44
|
-
readonly calls: Array<{ kind: ApiResourceKind; resourceId: string }> = [];
|
|
45
|
-
constructor(private readonly failKinds: ReadonlySet<ApiResourceKind> = new Set()) {}
|
|
46
|
-
|
|
47
|
-
async delete(kind: ApiResourceKind, resourceId: string): Promise<void> {
|
|
48
|
-
this.calls.push({ kind, resourceId });
|
|
49
|
-
if (this.failKinds.has(kind)) {
|
|
50
|
-
throw new Error("permission denied");
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let dir: string;
|
|
56
|
-
let store: SqliteStore;
|
|
57
|
-
|
|
58
|
-
beforeEach(() => {
|
|
59
|
-
dir = mkdtempSync(path.join(tmpdir(), "project-reconcile-test-"));
|
|
60
|
-
store = SqliteStore.open(path.join(dir, "test.db"));
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
afterEach(async () => {
|
|
64
|
-
await store.close();
|
|
65
|
-
rmSync(dir, { recursive: true, force: true });
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
function service(deleter: OrphanDeleter): ReconciliationService {
|
|
69
|
-
return newReconciliationService({
|
|
70
|
-
store,
|
|
71
|
-
orphanDeleter: () => deleter,
|
|
72
|
-
logger: silentLogger,
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function ref(kind: ApiResourceKind, slug: string): ApiResourceReference {
|
|
77
|
-
return create(ApiResourceReferenceSchema, { org: "local", kind, slug });
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function seedAgent(id: string, slug: string): Promise<void> {
|
|
81
|
-
await store.saveResource(
|
|
82
|
-
ApiResourceKind.agent,
|
|
83
|
-
id,
|
|
84
|
-
AgentSchema,
|
|
85
|
-
create(AgentSchema, {
|
|
86
|
-
apiVersion: "agentic.stigmer.ai/v1",
|
|
87
|
-
kind: "Agent",
|
|
88
|
-
metadata: { id, name: slug, slug, org: "local" },
|
|
89
|
-
}),
|
|
90
|
-
);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
async function seedWorkflow(id: string, slug: string): Promise<void> {
|
|
94
|
-
await store.saveResource(
|
|
95
|
-
ApiResourceKind.workflow,
|
|
96
|
-
id,
|
|
97
|
-
WorkflowSchema,
|
|
98
|
-
create(WorkflowSchema, {
|
|
99
|
-
apiVersion: "agentic.stigmer.ai/v1",
|
|
100
|
-
kind: "Workflow",
|
|
101
|
-
metadata: { id, name: slug, slug, org: "local" },
|
|
102
|
-
}),
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async function seedMcpServer(id: string, slug: string): Promise<void> {
|
|
107
|
-
await store.saveResource(
|
|
108
|
-
ApiResourceKind.mcp_server,
|
|
109
|
-
id,
|
|
110
|
-
McpServerSchema,
|
|
111
|
-
create(McpServerSchema, {
|
|
112
|
-
apiVersion: "agentic.stigmer.ai/v1",
|
|
113
|
-
kind: "McpServer",
|
|
114
|
-
metadata: { id, name: slug, slug, org: "local" },
|
|
115
|
-
}),
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function seedSkill(id: string, slug: string): Promise<void> {
|
|
120
|
-
await store.saveResource(
|
|
121
|
-
ApiResourceKind.skill,
|
|
122
|
-
id,
|
|
123
|
-
SkillSchema,
|
|
124
|
-
create(SkillSchema, {
|
|
125
|
-
apiVersion: "agentic.stigmer.ai/v1",
|
|
126
|
-
kind: "Skill",
|
|
127
|
-
metadata: { id, name: slug, slug, org: "local" },
|
|
128
|
-
}),
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
describe("reconcile set difference (Go service_test.go)", () => {
|
|
133
|
-
it("returns the empty result when both lists are empty", async () => {
|
|
134
|
-
const deleter = new RecordingDeleter();
|
|
135
|
-
const result = await service(deleter).reconcile([], [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
136
|
-
expect(result).toEqual({ added: [], removed: [], errors: [] });
|
|
137
|
-
expect(deleter.calls).toHaveLength(0);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it("returns the empty result when previous == current", async () => {
|
|
141
|
-
const deleter = new RecordingDeleter();
|
|
142
|
-
const members = [ref(ApiResourceKind.agent, "my-agent")];
|
|
143
|
-
const result = await service(deleter).reconcile(members, members, DEFAULT_RECONCILIATION_OPTIONS);
|
|
144
|
-
expect(result).toEqual({ added: [], removed: [], errors: [] });
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
it("first apply: all current members are added, none removed", async () => {
|
|
148
|
-
const deleter = new RecordingDeleter();
|
|
149
|
-
const current = [
|
|
150
|
-
ref(ApiResourceKind.agent, "agent-1"),
|
|
151
|
-
ref(ApiResourceKind.workflow, "wf-1"),
|
|
152
|
-
];
|
|
153
|
-
const result = await service(deleter).reconcile([], current, DEFAULT_RECONCILIATION_OPTIONS);
|
|
154
|
-
expect(result.added).toHaveLength(2);
|
|
155
|
-
expect(result.removed).toHaveLength(0);
|
|
156
|
-
expect(deleter.calls).toHaveLength(0);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("prune disabled: orphans are neither reported nor deleted", async () => {
|
|
160
|
-
const deleter = new RecordingDeleter();
|
|
161
|
-
const previous = [ref(ApiResourceKind.agent, "old-agent")];
|
|
162
|
-
const result = await service(deleter).reconcile(previous, [], {
|
|
163
|
-
pruneEnabled: false,
|
|
164
|
-
dryRun: false,
|
|
165
|
-
});
|
|
166
|
-
expect(result.removed).toHaveLength(0);
|
|
167
|
-
expect(deleter.calls).toHaveLength(0);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
it("dry run: orphans are reported in removed WITHOUT deleting", async () => {
|
|
171
|
-
const deleter = new RecordingDeleter();
|
|
172
|
-
const previous = [ref(ApiResourceKind.agent, "orphan-agent")];
|
|
173
|
-
const result = await service(deleter).reconcile(previous, [], {
|
|
174
|
-
pruneEnabled: true,
|
|
175
|
-
dryRun: true,
|
|
176
|
-
});
|
|
177
|
-
expect(result.removed).toHaveLength(1);
|
|
178
|
-
expect(result.removed[0]?.slug).toBe("orphan-agent");
|
|
179
|
-
expect(deleter.calls).toHaveLength(0);
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
it("dry run WINS over prune-disabled: orphans still reported (Go branch order)", async () => {
|
|
183
|
-
// Go checks dryRun BEFORE pruneEnabled (service.go:61-67), so this
|
|
184
|
-
// combination reports the orphan plan; prune-first ordering would
|
|
185
|
-
// return an empty removed list — the one combination that pins the
|
|
186
|
-
// branch precedence.
|
|
187
|
-
const deleter = new RecordingDeleter();
|
|
188
|
-
const previous = [ref(ApiResourceKind.agent, "orphan-agent")];
|
|
189
|
-
const result = await service(deleter).reconcile(previous, [], {
|
|
190
|
-
pruneEnabled: false,
|
|
191
|
-
dryRun: true,
|
|
192
|
-
});
|
|
193
|
-
expect(result.removed.map((r) => r.slug)).toEqual(["orphan-agent"]);
|
|
194
|
-
expect(deleter.calls).toHaveLength(0);
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it("mixed change reports exactly the delta (Go MixedAddedAndRemoved)", async () => {
|
|
198
|
-
await seedAgent("agent-id-old", "agent-old");
|
|
199
|
-
const deleter = new RecordingDeleter();
|
|
200
|
-
const previous = [
|
|
201
|
-
ref(ApiResourceKind.agent, "agent-old"),
|
|
202
|
-
ref(ApiResourceKind.agent, "agent-keep"),
|
|
203
|
-
];
|
|
204
|
-
const current = [
|
|
205
|
-
ref(ApiResourceKind.agent, "agent-keep"),
|
|
206
|
-
ref(ApiResourceKind.workflow, "wf-new"),
|
|
207
|
-
];
|
|
208
|
-
const result = await service(deleter).reconcile(previous, current, DEFAULT_RECONCILIATION_OPTIONS);
|
|
209
|
-
expect(result.added.map((r) => r.slug)).toEqual(["wf-new"]);
|
|
210
|
-
expect(result.removed.map((r) => r.slug)).toEqual(["agent-old"]);
|
|
211
|
-
});
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
describe("orphan deletion (Go service_test.go)", () => {
|
|
215
|
-
it("resolves each of the four member kinds by slug and routes its delete", async () => {
|
|
216
|
-
await seedAgent("agent-id-1", "orphan-agent");
|
|
217
|
-
await seedWorkflow("wf-id-1", "orphan-wf");
|
|
218
|
-
await seedMcpServer("mcps-id-1", "orphan-mcps");
|
|
219
|
-
await seedSkill("skill-id-1", "orphan-skill");
|
|
220
|
-
|
|
221
|
-
const deleter = new RecordingDeleter();
|
|
222
|
-
const previous = [
|
|
223
|
-
ref(ApiResourceKind.agent, "orphan-agent"),
|
|
224
|
-
ref(ApiResourceKind.workflow, "orphan-wf"),
|
|
225
|
-
ref(ApiResourceKind.mcp_server, "orphan-mcps"),
|
|
226
|
-
ref(ApiResourceKind.skill, "orphan-skill"),
|
|
227
|
-
];
|
|
228
|
-
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
229
|
-
|
|
230
|
-
expect(result.removed).toHaveLength(4);
|
|
231
|
-
expect(result.errors).toHaveLength(0);
|
|
232
|
-
expect(deleter.calls).toEqual([
|
|
233
|
-
{ kind: ApiResourceKind.agent, resourceId: "agent-id-1" },
|
|
234
|
-
{ kind: ApiResourceKind.workflow, resourceId: "wf-id-1" },
|
|
235
|
-
{ kind: ApiResourceKind.mcp_server, resourceId: "mcps-id-1" },
|
|
236
|
-
{ kind: ApiResourceKind.skill, resourceId: "skill-id-1" },
|
|
237
|
-
]);
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
it("orphan lookup is scoped to the reference's org: another org's same-slug resource is never touched", async () => {
|
|
241
|
-
// Slugs are org-scoped identifiers. Seeded under org "local"; the orphan
|
|
242
|
-
// reference names a DIFFERENT org, so it must not resolve to this row.
|
|
243
|
-
// (The Go port matched by slug alone; on a backend with a system org
|
|
244
|
-
// beside the user's, a prune could then delete the wrong org's
|
|
245
|
-
// resource — stigmer/stigmer#1151.)
|
|
246
|
-
await seedAgent("agent-id-x", "cross-org-agent");
|
|
247
|
-
const deleter = new RecordingDeleter();
|
|
248
|
-
const previous = [
|
|
249
|
-
create(ApiResourceReferenceSchema, {
|
|
250
|
-
org: "another-org",
|
|
251
|
-
kind: ApiResourceKind.agent,
|
|
252
|
-
slug: "cross-org-agent",
|
|
253
|
-
}),
|
|
254
|
-
];
|
|
255
|
-
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
256
|
-
expect(result.removed).toHaveLength(0);
|
|
257
|
-
expect(result.errors).toHaveLength(1);
|
|
258
|
-
expect(result.errors[0]?.resourceKey).toBe("agent:cross-org-agent");
|
|
259
|
-
expect(result.errors[0]?.message).toBe("failed to resolve resource for deletion");
|
|
260
|
-
expect(deleter.calls).toHaveLength(0);
|
|
261
|
-
});
|
|
262
|
-
|
|
263
|
-
it("orphan lookup picks the reference's org when two orgs hold the slug", async () => {
|
|
264
|
-
await seedAgent("agent-id-local", "shared-slug");
|
|
265
|
-
await store.saveResource(
|
|
266
|
-
ApiResourceKind.agent,
|
|
267
|
-
"agent-id-other",
|
|
268
|
-
AgentSchema,
|
|
269
|
-
create(AgentSchema, {
|
|
270
|
-
apiVersion: "agentic.stigmer.ai/v1",
|
|
271
|
-
kind: "Agent",
|
|
272
|
-
metadata: { id: "agent-id-other", name: "shared-slug", slug: "shared-slug", org: "another-org" },
|
|
273
|
-
}),
|
|
274
|
-
);
|
|
275
|
-
const deleter = new RecordingDeleter();
|
|
276
|
-
const previous = [
|
|
277
|
-
create(ApiResourceReferenceSchema, {
|
|
278
|
-
org: "another-org",
|
|
279
|
-
kind: ApiResourceKind.agent,
|
|
280
|
-
slug: "shared-slug",
|
|
281
|
-
}),
|
|
282
|
-
];
|
|
283
|
-
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
284
|
-
expect(result.removed).toHaveLength(1);
|
|
285
|
-
expect(deleter.calls).toEqual([
|
|
286
|
-
{ kind: ApiResourceKind.agent, resourceId: "agent-id-other" },
|
|
287
|
-
]);
|
|
288
|
-
});
|
|
289
|
-
|
|
290
|
-
it("a reference without an org keeps the unscoped lookup (a pre-scoping client's project)", async () => {
|
|
291
|
-
await seedAgent("agent-id-y", "legacy-agent");
|
|
292
|
-
const deleter = new RecordingDeleter();
|
|
293
|
-
const previous = [
|
|
294
|
-
create(ApiResourceReferenceSchema, {
|
|
295
|
-
kind: ApiResourceKind.agent,
|
|
296
|
-
slug: "legacy-agent",
|
|
297
|
-
}),
|
|
298
|
-
];
|
|
299
|
-
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
300
|
-
expect(result.removed).toHaveLength(1);
|
|
301
|
-
expect(deleter.calls).toEqual([
|
|
302
|
-
{ kind: ApiResourceKind.agent, resourceId: "agent-id-y" },
|
|
303
|
-
]);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
it("unresolvable slug: collected as an error, nothing deleted", async () => {
|
|
307
|
-
const deleter = new RecordingDeleter();
|
|
308
|
-
const previous = [ref(ApiResourceKind.agent, "ghost-agent")];
|
|
309
|
-
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
310
|
-
expect(result.removed).toHaveLength(0);
|
|
311
|
-
expect(result.errors).toHaveLength(1);
|
|
312
|
-
expect(result.errors[0]?.resourceKey).toBe("agent:ghost-agent");
|
|
313
|
-
expect(result.errors[0]?.message).toBe("failed to resolve resource for deletion");
|
|
314
|
-
expect(deleter.calls).toHaveLength(0);
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
it("unsupported member kind: an unsupported-kind resolution error", async () => {
|
|
318
|
-
const deleter = new RecordingDeleter();
|
|
319
|
-
const previous = [ref(ApiResourceKind.project, "nested-project")];
|
|
320
|
-
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
321
|
-
expect(result.removed).toHaveLength(0);
|
|
322
|
-
expect(result.errors).toHaveLength(1);
|
|
323
|
-
expect(result.errors[0]?.resourceKey).toBe("project:nested-project");
|
|
324
|
-
expect((result.errors[0]?.cause as Error).message).toBe(
|
|
325
|
-
"unsupported resource kind: project",
|
|
326
|
-
);
|
|
327
|
-
expect(deleter.calls).toHaveLength(0);
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
it("continues on failure: one failed delete never blocks the rest (Go PartialFailure)", async () => {
|
|
331
|
-
await seedAgent("agent-ok", "good-agent");
|
|
332
|
-
await seedWorkflow("wf-fail", "bad-workflow");
|
|
333
|
-
await seedSkill("skill-ok", "good-skill");
|
|
334
|
-
|
|
335
|
-
// Ordering matters: the failing workflow sits BETWEEN two successes,
|
|
336
|
-
// proving the loop continues past a failure in both directions.
|
|
337
|
-
const deleter = new RecordingDeleter(new Set([ApiResourceKind.workflow]));
|
|
338
|
-
const previous = [
|
|
339
|
-
ref(ApiResourceKind.agent, "good-agent"),
|
|
340
|
-
ref(ApiResourceKind.workflow, "bad-workflow"),
|
|
341
|
-
ref(ApiResourceKind.skill, "good-skill"),
|
|
342
|
-
];
|
|
343
|
-
const result = await service(deleter).reconcile(previous, [], DEFAULT_RECONCILIATION_OPTIONS);
|
|
344
|
-
|
|
345
|
-
expect(result.removed.map((r) => r.slug)).toEqual(["good-agent", "good-skill"]);
|
|
346
|
-
expect(result.errors).toHaveLength(1);
|
|
347
|
-
expect(result.errors[0]?.resourceKey).toBe("workflow:bad-workflow");
|
|
348
|
-
expect(result.errors[0]?.message).toBe("failed to delete orphaned resource");
|
|
349
|
-
expect(deleter.calls).toHaveLength(3);
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
it("a seeded resource without an id is a resolution error, not a delete", async () => {
|
|
353
|
-
// saveResource keys the row by the passed id, but the reconciler reads
|
|
354
|
-
// metadata.id off the unmarshaled message — absent means unresolvable
|
|
355
|
-
// (Go: "resource %s/%s has no ID").
|
|
356
|
-
await store.saveResource(
|
|
357
|
-
ApiResourceKind.agent,
|
|
358
|
-
"row-key-only",
|
|
359
|
-
AgentSchema,
|
|
360
|
-
create(AgentSchema, {
|
|
361
|
-
apiVersion: "agentic.stigmer.ai/v1",
|
|
362
|
-
kind: "Agent",
|
|
363
|
-
metadata: { name: "no-id", slug: "no-id", org: "local" },
|
|
364
|
-
}),
|
|
365
|
-
);
|
|
366
|
-
const deleter = new RecordingDeleter();
|
|
367
|
-
const result = await service(deleter).reconcile(
|
|
368
|
-
[ref(ApiResourceKind.agent, "no-id")],
|
|
369
|
-
[],
|
|
370
|
-
DEFAULT_RECONCILIATION_OPTIONS,
|
|
371
|
-
);
|
|
372
|
-
expect(result.errors).toHaveLength(1);
|
|
373
|
-
expect((result.errors[0]?.cause as Error).message).toBe(
|
|
374
|
-
"resource agent/no-id has no ID",
|
|
375
|
-
);
|
|
376
|
-
expect(deleter.calls).toHaveLength(0);
|
|
377
|
-
});
|
|
378
|
-
});
|
|
379
|
-
|
|
380
|
-
describe("referenceKey (Go TestReferenceKey)", () => {
|
|
381
|
-
it("renders the kind's enum NAME, exactly Go's %s", () => {
|
|
382
|
-
expect(referenceKey(ref(ApiResourceKind.agent, "my-agent"))).toBe("agent:my-agent");
|
|
383
|
-
expect(referenceKey(ref(ApiResourceKind.mcp_server, "tools"))).toBe("mcp_server:tools");
|
|
384
|
-
});
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
describe("toProtoSummary (Go ToProtoSummary)", () => {
|
|
388
|
-
it("maps added → created and removed → deleted; updated is ALWAYS empty", () => {
|
|
389
|
-
const added = [ref(ApiResourceKind.agent, "a1")];
|
|
390
|
-
const removed = [ref(ApiResourceKind.workflow, "w1")];
|
|
391
|
-
const summary = toProtoSummary({ added, removed, errors: [] });
|
|
392
|
-
expect(summary.created.map((r) => r.slug)).toEqual(["a1"]);
|
|
393
|
-
expect(summary.updated).toEqual([]);
|
|
394
|
-
expect(summary.deleted.map((r) => r.slug)).toEqual(["w1"]);
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
it("an all-empty result still yields a message (wire-visible presence)", () => {
|
|
398
|
-
const summary = toProtoSummary({ added: [], removed: [], errors: [] });
|
|
399
|
-
expect(summary.created).toEqual([]);
|
|
400
|
-
expect(summary.deleted).toEqual([]);
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it("a failed orphan is simply absent from deleted (errors never map)", () => {
|
|
404
|
-
const summary = toProtoSummary({
|
|
405
|
-
added: [],
|
|
406
|
-
removed: [ref(ApiResourceKind.agent, "deleted-ok")],
|
|
407
|
-
errors: [
|
|
408
|
-
{ resourceKey: "workflow:failed", message: "failed to delete orphaned resource", cause: new Error("x") },
|
|
409
|
-
],
|
|
410
|
-
});
|
|
411
|
-
expect(summary.deleted.map((r) => r.slug)).toEqual(["deleted-ok"]);
|
|
412
|
-
});
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
describe("project search extractor (Go project_extractor.go)", () => {
|
|
416
|
-
it("extracts name/description/tags/org/visibility-name/createdAt-seconds", () => {
|
|
417
|
-
const createdAt = timestampFromDate(new Date(1_700_000_000_000));
|
|
418
|
-
const project = create(ProjectSchema, {
|
|
419
|
-
metadata: {
|
|
420
|
-
id: "prj_x",
|
|
421
|
-
name: "Data Platform",
|
|
422
|
-
slug: "data-platform",
|
|
423
|
-
org: "acme",
|
|
424
|
-
tags: ["infra", "data"],
|
|
425
|
-
visibility: ApiResourceVisibility.visibility_private,
|
|
426
|
-
},
|
|
427
|
-
spec: { description: "the data platform project" },
|
|
428
|
-
status: { audit: { specAudit: { createdAt } } },
|
|
429
|
-
});
|
|
430
|
-
expect(projectSearchExtractor.getSearchIndexEntry(project)).toEqual({
|
|
431
|
-
name: "Data Platform",
|
|
432
|
-
description: "the data platform project",
|
|
433
|
-
tags: "infra data",
|
|
434
|
-
org: "acme",
|
|
435
|
-
visibility: "visibility_private",
|
|
436
|
-
createdAt: 1_700_000_000,
|
|
437
|
-
});
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
it("summary is empty without a spec; extraction skips without metadata (Go nil arms)", () => {
|
|
441
|
-
const noSpec = create(ProjectSchema, { metadata: { name: "n", org: "o" } });
|
|
442
|
-
expect(projectSearchExtractor.getSearchIndexEntry(noSpec)?.description).toBe("");
|
|
443
|
-
expect(
|
|
444
|
-
projectSearchExtractor.getSearchIndexEntry(create(ProjectSchema)),
|
|
445
|
-
).toBeUndefined();
|
|
446
|
-
});
|
|
447
|
-
});
|