@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.
Files changed (72) hide show
  1. package/dist/authorization/model/index.d.ts +1 -1
  2. package/dist/authorization/model/index.d.ts.map +1 -1
  3. package/dist/authorization/model/index.js +0 -2
  4. package/dist/authorization/model/index.js.map +1 -1
  5. package/dist/authorization/model/organization.d.ts.map +1 -1
  6. package/dist/authorization/model/organization.js +0 -1
  7. package/dist/authorization/model/organization.js.map +1 -1
  8. package/dist/boot/compose.d.ts.map +1 -1
  9. package/dist/boot/compose.js +2 -15
  10. package/dist/boot/compose.js.map +1 -1
  11. package/dist/boot/inprocess.d.ts +2 -13
  12. package/dist/boot/inprocess.d.ts.map +1 -1
  13. package/dist/boot/inprocess.js +6 -16
  14. package/dist/boot/inprocess.js.map +1 -1
  15. package/dist/domain/workflow/registry/data/task-kind-registry.json +0 -1
  16. package/dist/pipeline/interceptors/auth.d.ts +1 -1
  17. package/dist/pipeline/interceptors/auth.js +1 -1
  18. package/dist/query/search/registry.d.ts +3 -5
  19. package/dist/query/search/registry.d.ts.map +1 -1
  20. package/dist/query/search/registry.js +3 -7
  21. package/dist/query/search/registry.js.map +1 -1
  22. package/dist/store/postgres/migrations.d.ts +3 -1
  23. package/dist/store/postgres/migrations.d.ts.map +1 -1
  24. package/dist/store/postgres/migrations.js +19 -1
  25. package/dist/store/postgres/migrations.js.map +1 -1
  26. package/dist/store/sqlite/migrations.d.ts +3 -1
  27. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  28. package/dist/store/sqlite/migrations.js +22 -1
  29. package/dist/store/sqlite/migrations.js.map +1 -1
  30. package/package.json +6 -6
  31. package/src/authorization/__tests__/derived-tuples.test.ts +1 -1
  32. package/src/authorization/__tests__/fixtures/fga/org-admin-owner-inheritance.fga.yaml +6 -6
  33. package/src/authorization/__tests__/wire-permissions.test.ts +0 -1
  34. package/src/authorization/model/__tests__/registry.test.ts +0 -9
  35. package/src/authorization/model/index.ts +1 -3
  36. package/src/authorization/model/organization.ts +0 -1
  37. package/src/boot/compose.ts +2 -15
  38. package/src/boot/inprocess.ts +8 -29
  39. package/src/domain/iampolicy/__tests__/role-lifecycle.test.ts +0 -1
  40. package/src/domain/workflow/registry/data/task-kind-registry.json +0 -1
  41. package/src/pipeline/__tests__/router-transport.test.ts +1 -1
  42. package/src/pipeline/interceptors/auth.ts +1 -1
  43. package/src/query/__tests__/query-services.test.ts +16 -18
  44. package/src/query/search/__tests__/criteria.test.ts +3 -6
  45. package/src/query/search/__tests__/query-store.test.ts +21 -21
  46. package/src/query/search/registry.ts +3 -7
  47. package/src/store/postgres/__tests__/migrations.test.ts +23 -4
  48. package/src/store/postgres/migrations.ts +20 -1
  49. package/src/store/sqlite/__tests__/migrations.test.ts +26 -2
  50. package/src/store/sqlite/migrations.ts +23 -1
  51. package/dist/authorization/model/project.d.ts +0 -2
  52. package/dist/authorization/model/project.d.ts.map +0 -1
  53. package/dist/authorization/model/project.js +0 -32
  54. package/dist/authorization/model/project.js.map +0 -1
  55. package/dist/domain/project/controller.d.ts +0 -48
  56. package/dist/domain/project/controller.d.ts.map +0 -1
  57. package/dist/domain/project/controller.js +0 -192
  58. package/dist/domain/project/controller.js.map +0 -1
  59. package/dist/domain/project/reconcile.d.ts +0 -77
  60. package/dist/domain/project/reconcile.d.ts.map +0 -1
  61. package/dist/domain/project/reconcile.js +0 -179
  62. package/dist/domain/project/reconcile.js.map +0 -1
  63. package/dist/domain/project/search-extractor.d.ts +0 -3
  64. package/dist/domain/project/search-extractor.d.ts.map +0 -1
  65. package/dist/domain/project/search-extractor.js +0 -43
  66. package/dist/domain/project/search-extractor.js.map +0 -1
  67. package/src/authorization/model/project.ts +0 -47
  68. package/src/domain/project/__tests__/project.composed.test.ts +0 -381
  69. package/src/domain/project/__tests__/reconcile.test.ts +0 -447
  70. package/src/domain/project/controller.ts +0 -387
  71. package/src/domain/project/reconcile.ts +0 -296
  72. package/src/domain/project/search-extractor.ts +0 -71
@@ -1,387 +0,0 @@
1
- /**
2
- * Project controller — ports pkg/domain/project/controller (command + query
3
- * sides). Project is the aggregate root grouping related resources: its spec
4
- * carries lightweight ApiResourceReference members the CLI derives from
5
- * individual apply responses (never user-authored); apply reconciles the
6
- * membership and prunes orphans through the four downstream domains'
7
- * in-process clients — the widest client fan-out of any CRUD domain, which
8
- * is why it ported last of Class A (D4 #16).
9
- *
10
- * Pipeline per RPC mirrors the Go step chains character-for-character; the
11
- * create/update ASYMMETRY is contract: create runs ValidateVisibility and
12
- * NormalizeReferences, update runs neither (an updated members list persists
13
- * without reference normalization — Go update.go). No chain runs
14
- * ValidateReferences: members referencing nonexistent resources persist
15
- * silently. Delete removes ONLY the project — members are never cascaded.
16
- *
17
- * Proven by project.conformance.test.ts (CONFORMANCE_TARGET=local) and
18
- * __tests__/{reconcile,project.composed}.test.ts (the conformance suite
19
- * never touches spec.members or last_reconciliation; the composed tests own
20
- * those pins).
21
- *
22
- * Versus Stigmer Cloud, OSS excludes the Authorize, CreateIamPolicies,
23
- * Publish, and TransformResponse steps (no multi-tenant auth, IAM/FGA, or
24
- * event publishing here).
25
- */
26
- import type { ConnectRouter, HandlerContext } from "@connectrpc/connect";
27
- import { create } from "@bufbuild/protobuf";
28
-
29
- import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
30
- import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
31
- import { ProjectSchema } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/api_pb";
32
- import type { Project } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/api_pb";
33
- import { ProjectCommandController } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/command_pb";
34
- import type { ProjectId } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/io_pb";
35
- import { ProjectQueryController } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/query_pb";
36
- import { ProjectStatusSchema } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/status_pb";
37
-
38
- import type { Logger } from "../../boot/logger.js";
39
- import type { Authorizer } from "../../extensions/authorizer.js";
40
- import type { ResourceAuthorizationLifecycle } from "../../extensions/resource-authorization.js";
41
- import { internalError } from "../../pipeline/errors.js";
42
- import { apiResourceKindKey } from "../../pipeline/interceptors/apiresource.js";
43
- import { newPipeline } from "../../pipeline/pipeline.js";
44
- import { callerIdentityOf } from "../../pipeline/interceptors/auth.js";
45
- import { RequestContext } from "../../pipeline/request-context.js";
46
- import { newAuthorizeStep } from "../../pipeline/steps/authorize.js";
47
- import { newGuardReservedLabelsStep } from "../../pipeline/steps/guard-reserved-labels.js";
48
- import { newBuildUpdateStateStep } from "../../pipeline/steps/build-update-state.js";
49
- import { newBuildNewStateStep } from "../../pipeline/steps/defaults.js";
50
- import {
51
- newDeleteResourceStep,
52
- newExtractResourceIdStep,
53
- newLoadExistingForDeleteStep,
54
- } from "../../pipeline/steps/delete.js";
55
- import { newCheckDuplicateStep } from "../../pipeline/steps/duplicate.js";
56
- import {
57
- newDeleteSearchIndexStep,
58
- newIndexSearchStep,
59
- } from "../../pipeline/steps/index-search.js";
60
- import { newLoadByReferenceStep } from "../../pipeline/steps/load-by-reference.js";
61
- import {
62
- EXISTING_RESOURCE_KEY,
63
- newLoadExistingStep,
64
- } from "../../pipeline/steps/load-existing.js";
65
- import {
66
- SHOULD_CREATE_KEY,
67
- newLoadForApplyStep,
68
- withResolvedApplyId,
69
- } from "../../pipeline/steps/load-for-apply.js";
70
- import {
71
- TARGET_RESOURCE_KEY,
72
- newLoadTargetStep,
73
- } from "../../pipeline/steps/load-target.js";
74
- import {
75
- newCleanupIamPoliciesStep,
76
- newCreateAuthorizationTuplesStep,
77
- } from "../../pipeline/steps/authorization-tuples.js";
78
- import { newPersistStep } from "../../pipeline/steps/persist.js";
79
- import { newNormalizeReferencesStep } from "../../pipeline/steps/references.js";
80
- import { newResolveSlugStep } from "../../pipeline/steps/slug.js";
81
- import { newValidateVisibilityStep } from "../../pipeline/steps/validate-visibility.js";
82
- import { newValidateProtoStep } from "../../pipeline/steps/validation.js";
83
- import type { Store } from "../../store/interface.js";
84
- import {
85
- DEFAULT_RECONCILIATION_OPTIONS,
86
- newReconciliationService,
87
- toProtoSummary,
88
- } from "./reconcile.js";
89
- import type { OrphanDeleter, ReconciliationService } from "./reconcile.js";
90
- import { projectSearchExtractor } from "./search-extractor.js";
91
-
92
- export interface ProjectControllerDeps {
93
- readonly store: Store;
94
- readonly logger: Logger;
95
- /** The composed authorization seam — the Authorize step at position 1 of every chain calls it (O2, DD-007 §3). */
96
- readonly authorizer: Authorizer;
97
- /** The composed tuple-lifecycle driver — undefined = the shared steps no-op (C2). */
98
- readonly authorizationLifecycle: ResourceAuthorizationLifecycle | undefined;
99
- /**
100
- * The four downstream delete edges (agent/workflow/mcp_server/skill),
101
- * lazy per the compose root's boot-ordering idiom — the TS replacement
102
- * for Go's SetReconciliationService late-bind (server.go:635-648).
103
- */
104
- readonly orphanDeleter: () => OrphanDeleter;
105
- }
106
-
107
- /** Registers both project services on the router (routes stage). */
108
- export function registerProjectServices(
109
- router: ConnectRouter,
110
- deps: ProjectControllerDeps,
111
- ): void {
112
- const reconciliation = newReconciliationService({
113
- store: deps.store,
114
- orphanDeleter: deps.orphanDeleter,
115
- logger: deps.logger,
116
- });
117
- router.service(ProjectCommandController, {
118
- apply: (project, ctx) => apply(deps, reconciliation, project, ctx),
119
- create: (project, ctx) => createProject(deps, project, ctx),
120
- update: (project, ctx) => update(deps, project, ctx),
121
- delete: (projectId, ctx) => deleteProject(deps, projectId, ctx),
122
- });
123
- router.service(ProjectQueryController, {
124
- get: (projectId, ctx) => get(deps, projectId, ctx),
125
- getByReference: (ref, ctx) => getByReference(deps, ref, ctx),
126
- });
127
- }
128
-
129
- function kindOf(ctx: HandlerContext): ApiResourceKind {
130
- return ctx.values.get(apiResourceKindKey);
131
- }
132
-
133
- /** Create — chain per Go buildCreatePipeline (create.go:54-63). */
134
- async function createProject(
135
- deps: ProjectControllerDeps,
136
- project: Project,
137
- ctx: HandlerContext,
138
- ): Promise<Project> {
139
- const reqCtx = new RequestContext(
140
- ProjectSchema,
141
- project,
142
- callerIdentityOf(ctx),
143
- kindOf(ctx),
144
- );
145
- await newPipeline<typeof ProjectSchema>("project-create", deps.logger)
146
- .addStep(
147
- newAuthorizeStep(ProjectCommandController.method.create, deps.authorizer),
148
- )
149
- .addStep(newValidateProtoStep())
150
- .addStep(newValidateVisibilityStep())
151
- .addStep(newResolveSlugStep())
152
- .addStep(newCheckDuplicateStep(deps.store))
153
- .addStep(newBuildNewStateStep())
154
- .addStep(newGuardReservedLabelsStep(deps.authorizer))
155
- .addStep(newNormalizeReferencesStep())
156
- .addStep(newPersistStep(deps.store))
157
- .addStep(
158
- newCreateAuthorizationTuplesStep(
159
- deps.authorizationLifecycle,
160
- deps.logger,
161
- ),
162
- )
163
- .addStep(
164
- newIndexSearchStep(deps.store, projectSearchExtractor, deps.logger),
165
- )
166
- .build()
167
- .execute(reqCtx);
168
- return reqCtx.newState;
169
- }
170
-
171
- /**
172
- * Update — chain per Go buildUpdatePipeline (update.go:55-62). Deliberately
173
- * narrower than create (no ValidateVisibility, no NormalizeReferences —
174
- * the asymmetry is contract; see the module header).
175
- */
176
- async function update(
177
- deps: ProjectControllerDeps,
178
- project: Project,
179
- ctx: HandlerContext,
180
- ): Promise<Project> {
181
- const reqCtx = new RequestContext(
182
- ProjectSchema,
183
- project,
184
- callerIdentityOf(ctx),
185
- kindOf(ctx),
186
- );
187
- await newPipeline<typeof ProjectSchema>("project-update", deps.logger)
188
- .addStep(
189
- newAuthorizeStep(ProjectCommandController.method.update, deps.authorizer),
190
- )
191
- .addStep(newValidateProtoStep())
192
- .addStep(newResolveSlugStep())
193
- .addStep(newLoadExistingStep(deps.store))
194
- .addStep(newBuildUpdateStateStep())
195
- .addStep(newPersistStep(deps.store))
196
- .addStep(
197
- newIndexSearchStep(deps.store, projectSearchExtractor, deps.logger),
198
- )
199
- .build()
200
- .execute(reqCtx);
201
- return reqCtx.newState;
202
- }
203
-
204
- /**
205
- * Apply — declarative create-or-update PLUS membership reconciliation
206
- * (apply.go:29-85): a minimal pipeline decides existence, the previous
207
- * members are captured BEFORE delegation overwrites them, the full Create
208
- * or Update handler runs, then the reconciler diffs previous vs persisted
209
- * members and prunes orphans.
210
- *
211
- * Two ported postures:
212
- * - A reconcile failure is logged and SWALLOWED — apply still succeeds,
213
- * returning the persisted project without a summary (apply.go:77-82;
214
- * defensive in both editions — the reconciler never throws).
215
- * - On success the summary is set UNCONDITIONALLY, so every successful
216
- * apply response carries status.last_reconciliation, even as an empty
217
- * message. Response-only — never persisted.
218
- */
219
- async function apply(
220
- deps: ProjectControllerDeps,
221
- reconciliation: ReconciliationService,
222
- project: Project,
223
- ctx: HandlerContext,
224
- ): Promise<Project> {
225
- const reqCtx = new RequestContext(
226
- ProjectSchema,
227
- project,
228
- callerIdentityOf(ctx),
229
- kindOf(ctx),
230
- );
231
- await newPipeline<typeof ProjectSchema>("project-apply", deps.logger)
232
- .addStep(
233
- newAuthorizeStep(ProjectCommandController.method.apply, deps.authorizer),
234
- )
235
- .addStep(newValidateProtoStep())
236
- .addStep(newResolveSlugStep())
237
- .addStep(newLoadForApplyStep(deps.store))
238
- .build()
239
- .execute(reqCtx);
240
-
241
- const shouldCreate = reqCtx.get(SHOULD_CREATE_KEY);
242
- if (typeof shouldCreate !== "boolean") {
243
- throw internalError(
244
- new Error("apply pipeline did not set shouldCreate flag"),
245
- "apply operation failed to determine create vs update",
246
- );
247
- }
248
-
249
- // Captured before Create/Update runs; on first apply (create) there are
250
- // no previous members (Go apply.go:45-50).
251
- const previousMembers: ReadonlyArray<ApiResourceReference> = shouldCreate
252
- ? []
253
- : ((reqCtx.get(EXISTING_RESOURCE_KEY) as Project | undefined)?.spec
254
- ?.members ?? []);
255
-
256
- const persisted = shouldCreate
257
- ? await createProject(deps, project, ctx)
258
- : await update(
259
- deps,
260
- withResolvedApplyId(ProjectSchema, project, reqCtx),
261
- ctx,
262
- );
263
-
264
- const currentMembers = persisted.spec?.members ?? [];
265
-
266
- try {
267
- const result = await reconciliation.reconcile(
268
- previousMembers,
269
- currentMembers,
270
- DEFAULT_RECONCILIATION_OPTIONS,
271
- );
272
- persisted.status ??= create(ProjectStatusSchema);
273
- persisted.status.lastReconciliation = toProtoSummary(result);
274
- } catch (error) {
275
- deps.logger.error("Reconciliation failed", {
276
- projectId: persisted.metadata?.id ?? "",
277
- error: error instanceof Error ? error.message : String(error),
278
- });
279
- }
280
- return persisted;
281
- }
282
-
283
- /**
284
- * Delete — returns the pre-delete resource (delete.go). Deletes ONLY the
285
- * project entity: member resources are never cascaded.
286
- */
287
- async function deleteProject(
288
- deps: ProjectControllerDeps,
289
- projectId: ProjectId,
290
- ctx: HandlerContext,
291
- ): Promise<Project> {
292
- const reqCtx = new RequestContext(
293
- ProjectCommandController.method.delete.input,
294
- projectId,
295
- callerIdentityOf(ctx),
296
- kindOf(ctx),
297
- );
298
- await newPipeline<typeof ProjectCommandController.method.delete.input>(
299
- "project-delete",
300
- deps.logger,
301
- )
302
- .addStep(
303
- newAuthorizeStep(ProjectCommandController.method.delete, deps.authorizer),
304
- )
305
- .addStep(newValidateProtoStep())
306
- .addStep(newExtractResourceIdStep())
307
- .addStep(newLoadExistingForDeleteStep(deps.store, ProjectSchema))
308
- .addStep(newDeleteResourceStep(deps.store))
309
- .addStep(
310
- newCleanupIamPoliciesStep(deps.authorizationLifecycle, deps.logger),
311
- )
312
- .addStep(newDeleteSearchIndexStep(deps.store, deps.logger))
313
- .build()
314
- .execute(reqCtx);
315
-
316
- const deleted = reqCtx.get(EXISTING_RESOURCE_KEY);
317
- if (deleted === undefined) {
318
- // Go puts this text ON THE WIRE (delete.go:43, InternalError(nil, …));
319
- // the arm is unreachable in practice but the copy is byte-pinned.
320
- throw internalError(
321
- new Error("delete pipeline completed without a loaded resource"),
322
- "deleted project not found in context",
323
- );
324
- }
325
- return deleted as Project;
326
- }
327
-
328
- /** Get — LoadTarget by id; NotFound when absent (get.go). */
329
- async function get(
330
- deps: ProjectControllerDeps,
331
- projectId: ProjectId,
332
- ctx: HandlerContext,
333
- ): Promise<Project> {
334
- const reqCtx = new RequestContext(
335
- ProjectQueryController.method.get.input,
336
- projectId,
337
- callerIdentityOf(ctx),
338
- kindOf(ctx),
339
- );
340
- await newPipeline<typeof ProjectQueryController.method.get.input>(
341
- "project-get",
342
- deps.logger,
343
- )
344
- .addStep(
345
- newAuthorizeStep(ProjectQueryController.method.get, deps.authorizer),
346
- )
347
- .addStep(newValidateProtoStep())
348
- .addStep(newLoadTargetStep(deps.store, ProjectSchema))
349
- .build()
350
- .execute(reqCtx);
351
- return reqCtx.get(TARGET_RESOURCE_KEY) as Project;
352
- }
353
-
354
- /**
355
- * GetByReference — slug lookup within an org (get_by_reference.go). Project
356
- * is org-scoped, so an empty-org reference is rejected InvalidArgument by
357
- * the shared step's kind_meta scope check — a bare slug is under-specified,
358
- * never a global search (the conformance suite pins this; Go's file-level
359
- * doc comment claiming a platform-scoped fallback is stale).
360
- */
361
- async function getByReference(
362
- deps: ProjectControllerDeps,
363
- ref: ApiResourceReference,
364
- ctx: HandlerContext,
365
- ): Promise<Project> {
366
- const reqCtx = new RequestContext(
367
- ProjectQueryController.method.getByReference.input,
368
- ref,
369
- callerIdentityOf(ctx),
370
- kindOf(ctx),
371
- );
372
- await newPipeline<typeof ProjectQueryController.method.getByReference.input>(
373
- "project-get-by-reference",
374
- deps.logger,
375
- )
376
- .addStep(
377
- newAuthorizeStep(
378
- ProjectQueryController.method.getByReference,
379
- deps.authorizer,
380
- ),
381
- )
382
- .addStep(newValidateProtoStep())
383
- .addStep(newLoadByReferenceStep(deps.store, ProjectSchema))
384
- .build()
385
- .execute(reqCtx);
386
- return reqCtx.get(TARGET_RESOURCE_KEY) as Project;
387
- }
@@ -1,296 +0,0 @@
1
- /**
2
- * Project membership reconciliation — ports pkg/domain/project/reconcile/
3
- * (service.go, execution_engine.go, reconciliation_{result,options,error}.go).
4
- * Proven by __tests__/reconcile.test.ts and the apply→prune composed arms in
5
- * __tests__/project.composed.test.ts.
6
- *
7
- * Apply compares the previous project's spec.members with the newly persisted
8
- * spec.members, both keyed "{kind}:{slug}" (the kind's enum NAME — Go's %s).
9
- * Members only in the current list are ADDED; members only in the previous
10
- * list are ORPHANS and, with pruning enabled, are deleted through the four
11
- * downstream domains' in-process clients — their FULL delete pipelines, so a
12
- * referentially-blocked orphan fails exactly as an external delete would.
13
- *
14
- * Error posture (Go, verified): reconcile NEVER throws. Per-orphan failures
15
- * are collected on the result and are log-only — ToProtoSummary carries only
16
- * created/deleted, so a failed orphan is simply absent from `deleted`. The
17
- * wire surface of this whole module is those two lists.
18
- *
19
- * Deliberately not ported (plan-gate decisions, tasks/T01_0_plan.md):
20
- * - Go's nil-deleter stub arm (service.go:95-98) and the
21
- * SetReconciliationService late-bind — both exist only for Go's
22
- * registration-before-clients boot window; the compose root's lazy
23
- * provider closes that window, so here a deleter always exists.
24
- * - ResultBuilder and the DryRun/NoPrune option factories — zero non-test
25
- * uses in Go; the sole production call is
26
- * reconcile(previous, current, DEFAULT_RECONCILIATION_OPTIONS).
27
- */
28
- import { create } from "@bufbuild/protobuf";
29
-
30
- import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
31
- import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
32
- import { SkillSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
33
- import { WorkflowSchema } from "@stigmer/protos/ai/stigmer/agentic/workflow/v1/api_pb";
34
- import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
35
- import type { ApiResourceReference } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
36
- import { ReconciliationSummarySchema } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/status_pb";
37
- import type { ReconciliationSummary } from "@stigmer/protos/ai/stigmer/tenancy/project/v1/status_pb";
38
-
39
- import type { Logger } from "../../boot/logger.js";
40
- import { findResourceBySlug } from "../../pipeline/steps/helpers.js";
41
- import { metadataOf } from "../../pipeline/steps/shapes.js";
42
- import type { Store } from "../../store/interface.js";
43
-
44
- /**
45
- * Deletes one orphaned resource by kind and id — Go's ResourceDeleter, the
46
- * only execution capability the reference model needs (resources are
47
- * created/updated individually by the CLI; the server only deletes orphans).
48
- * Implemented over the four in-process command clients (boot/inprocess.ts).
49
- */
50
- export interface OrphanDeleter {
51
- delete(kind: ApiResourceKind, resourceId: string): Promise<void>;
52
- }
53
-
54
- /**
55
- * Go ReconciliationOptions, reduced to the two flags the algorithm reads.
56
- * Production always passes DEFAULT_RECONCILIATION_OPTIONS; the other
57
- * combinations remain reachable for tests because the branches are part of
58
- * the ported algorithm.
59
- */
60
- export interface ReconciliationOptions {
61
- /** When true, orphaned resources are deleted (Go default). */
62
- readonly pruneEnabled: boolean;
63
- /** When true, the plan is computed but nothing is deleted. */
64
- readonly dryRun: boolean;
65
- }
66
-
67
- export const DEFAULT_RECONCILIATION_OPTIONS: ReconciliationOptions = {
68
- pruneEnabled: true,
69
- dryRun: false,
70
- };
71
-
72
- /** One per-orphan failure — log-only, never wire-visible (Go, verified). */
73
- export interface ReconciliationError {
74
- /** "{kind}:{slug}" of the orphan that failed. */
75
- readonly resourceKey: string;
76
- readonly message: string;
77
- readonly cause: unknown;
78
- }
79
-
80
- /** Go ReconciliationResult, as a plain immutable value. */
81
- export interface ReconciliationResult {
82
- /** References in current but not previous — newly associated members. */
83
- readonly added: ReadonlyArray<ApiResourceReference>;
84
- /**
85
- * Orphans successfully deleted (or, on a dry run, the orphans that WOULD
86
- * be deleted — Go reports them in the same slot without deleting).
87
- */
88
- readonly removed: ReadonlyArray<ApiResourceReference>;
89
- readonly errors: ReadonlyArray<ReconciliationError>;
90
- }
91
-
92
- export interface ReconciliationService {
93
- /**
94
- * Compares memberships and prunes orphans per the options. NEVER throws —
95
- * Go's implementation always returns a nil error; failures ride
96
- * result.errors (and the apply handler's error-swallow arm upstream is
97
- * therefore defensive dead code in both editions, ported anyway).
98
- */
99
- reconcile(
100
- previousMembers: ReadonlyArray<ApiResourceReference>,
101
- currentMembers: ReadonlyArray<ApiResourceReference>,
102
- options: ReconciliationOptions,
103
- ): Promise<ReconciliationResult>;
104
- }
105
-
106
- export interface ReconciliationServiceDeps {
107
- readonly store: Store;
108
- /**
109
- * Lazy per the compose root's boot-ordering idiom: the in-process clients
110
- * behind the deleter exist only after routes registration completes.
111
- */
112
- readonly orphanDeleter: () => OrphanDeleter;
113
- readonly logger: Logger;
114
- }
115
-
116
- export function newReconciliationService(
117
- deps: ReconciliationServiceDeps,
118
- ): ReconciliationService {
119
- return {
120
- async reconcile(previousMembers, currentMembers, options) {
121
- const previousSet = buildReferenceSet(previousMembers);
122
- const currentSet = buildReferenceSet(currentMembers);
123
-
124
- const added = currentMembers.filter(
125
- (ref) => !previousSet.has(referenceKey(ref)),
126
- );
127
- const orphans = previousMembers.filter(
128
- (ref) => !currentSet.has(referenceKey(ref)),
129
- );
130
-
131
- if (added.length === 0 && orphans.length === 0) {
132
- return { added: [], removed: [], errors: [] };
133
- }
134
-
135
- // Dry run reports the orphans in `removed` WITHOUT deleting (Go
136
- // service.go:61-63 passes them straight through).
137
- if (options.dryRun) {
138
- return { added, removed: orphans, errors: [] };
139
- }
140
-
141
- if (!options.pruneEnabled || orphans.length === 0) {
142
- return { added, removed: [], errors: [] };
143
- }
144
-
145
- const { removed, errors } = await deleteOrphans(deps, orphans);
146
- return { added, removed, errors };
147
- },
148
- };
149
- }
150
-
151
- /**
152
- * Deletes each orphan, continuing on failure (Go deleteOrphans). Failures
153
- * are warned here — Go collects them onto a result nothing in production
154
- * reads, which leaves operators blind; the log is edition-local and the
155
- * wire behavior is identical.
156
- */
157
- async function deleteOrphans(
158
- deps: ReconciliationServiceDeps,
159
- orphans: ReadonlyArray<ApiResourceReference>,
160
- ): Promise<{
161
- removed: ApiResourceReference[];
162
- errors: ReconciliationError[];
163
- }> {
164
- const removed: ApiResourceReference[] = [];
165
- const errors: ReconciliationError[] = [];
166
-
167
- for (const ref of orphans) {
168
- const refKey = referenceKey(ref);
169
-
170
- let resourceId: string;
171
- try {
172
- resourceId = await resolveResourceId(deps.store, ref);
173
- } catch (cause) {
174
- errors.push({
175
- resourceKey: refKey,
176
- message: "failed to resolve resource for deletion",
177
- cause,
178
- });
179
- deps.logger.warn("reconciliation: failed to resolve orphan for deletion", {
180
- resource: refKey,
181
- error: cause instanceof Error ? cause.message : String(cause),
182
- });
183
- continue;
184
- }
185
-
186
- try {
187
- await deps.orphanDeleter().delete(ref.kind, resourceId);
188
- } catch (cause) {
189
- errors.push({
190
- resourceKey: refKey,
191
- message: "failed to delete orphaned resource",
192
- cause,
193
- });
194
- deps.logger.warn("reconciliation: failed to delete orphaned resource", {
195
- resource: refKey,
196
- resourceId,
197
- error: cause instanceof Error ? cause.message : String(cause),
198
- });
199
- continue;
200
- }
201
-
202
- removed.push(ref);
203
- }
204
-
205
- return { removed, errors };
206
- }
207
-
208
- /**
209
- * Resolves an orphan reference to its resource id, within the org the
210
- * reference names. Slugs are org-scoped identifiers: a local backend holds
211
- * the `stigmer` system org beside the user's, a self-host holds many, and
212
- * the same slug may exist in several. The Go port matched by slug alone and
213
- * could prune another org's resource (stigmer/stigmer#1151); the CLI has
214
- * always stamped `org` on every member reference, so scoping is a change
215
- * of lookup, not of contract. A reference without an org (a project written
216
- * before members carried one) keeps the unscoped match, the helper's
217
- * documented empty-org arm.
218
- */
219
- async function resolveResourceId(
220
- store: Store,
221
- ref: ApiResourceReference,
222
- ): Promise<string> {
223
- const schema = schemaForKind(ref.kind);
224
- const resource = await findResourceBySlug(
225
- store,
226
- ref.kind,
227
- schema,
228
- ref.slug,
229
- ref.org,
230
- );
231
- if (resource === undefined) {
232
- const where = ref.org === "" ? "" : ` in org '${ref.org}'`;
233
- throw new Error(
234
- `resource ${kindName(ref.kind)}/${ref.slug} not found${where}`,
235
- );
236
- }
237
-
238
- const id = metadataOf(resource)?.id ?? "";
239
- if (id === "") {
240
- throw new Error(`resource ${kindName(ref.kind)}/${ref.slug} has no ID`);
241
- }
242
- return id;
243
- }
244
-
245
- /**
246
- * The four member kinds the reconciler supports (Go newProtoForKind);
247
- * anything else is an unsupported-kind resolution error.
248
- */
249
- function schemaForKind(kind: ApiResourceKind) {
250
- switch (kind) {
251
- case ApiResourceKind.agent:
252
- return AgentSchema;
253
- case ApiResourceKind.workflow:
254
- return WorkflowSchema;
255
- case ApiResourceKind.mcp_server:
256
- return McpServerSchema;
257
- case ApiResourceKind.skill:
258
- return SkillSchema;
259
- default:
260
- throw new Error(`unsupported resource kind: ${kindName(kind)}`);
261
- }
262
- }
263
-
264
- /**
265
- * "{kind}:{slug}" with the kind's enum NAME — Go fmt %s of the enum (e.g.
266
- * "agent:my-agent", "mcp_server:tools"). The numeric form would diverge
267
- * from Go's log/error copy.
268
- */
269
- export function referenceKey(ref: ApiResourceReference): string {
270
- return `${kindName(ref.kind)}:${ref.slug}`;
271
- }
272
-
273
- function kindName(kind: ApiResourceKind): string {
274
- return ApiResourceKind[kind] ?? String(kind);
275
- }
276
-
277
- function buildReferenceSet(
278
- refs: ReadonlyArray<ApiResourceReference>,
279
- ): Set<string> {
280
- return new Set(refs.map(referenceKey));
281
- }
282
-
283
- /**
284
- * Go ToProtoSummary: added → created, updated ALWAYS empty (not applicable
285
- * in the reference model), removed → deleted. Every successful apply sets
286
- * the summary — even an all-empty one is a PRESENT message on the wire.
287
- */
288
- export function toProtoSummary(
289
- result: ReconciliationResult,
290
- ): ReconciliationSummary {
291
- return create(ReconciliationSummarySchema, {
292
- created: [...result.added],
293
- updated: [],
294
- deleted: [...result.removed],
295
- });
296
- }