@intx/hub-sessions 0.1.2 → 0.3.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 (113) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +82 -1
  3. package/dist/agent-repo.d.ts +93 -0
  4. package/dist/agent-repo.js +109 -0
  5. package/dist/agent-state-kind.d.ts +12 -0
  6. package/dist/agent-state-kind.js +189 -0
  7. package/dist/asset-service.d.ts +104 -0
  8. package/dist/asset-service.js +267 -0
  9. package/dist/committed-source-tree.d.ts +10 -0
  10. package/dist/committed-source-tree.js +35 -0
  11. package/dist/credential-push.d.ts +33 -0
  12. package/dist/credential-push.js +109 -0
  13. package/dist/event-collector-registry.d.ts +20 -0
  14. package/dist/event-collector-registry.js +115 -0
  15. package/dist/event-collector.d.ts +39 -0
  16. package/dist/event-collector.js +365 -0
  17. package/dist/hub-session-lookups.d.ts +135 -0
  18. package/dist/hub-session-lookups.js +663 -0
  19. package/dist/hub-session-orchestrator.d.ts +25 -0
  20. package/dist/hub-session-orchestrator.js +87 -0
  21. package/dist/index.d.ts +27 -0
  22. package/dist/index.js +24 -0
  23. package/dist/package-registry-kind.d.ts +70 -0
  24. package/dist/package-registry-kind.js +260 -0
  25. package/dist/repo-store/index.d.ts +4 -0
  26. package/dist/repo-store/index.js +3 -0
  27. package/dist/repo-store/store.d.ts +41 -0
  28. package/dist/repo-store/store.js +1829 -0
  29. package/dist/repo-store/subscribe-kind.d.ts +56 -0
  30. package/dist/repo-store/subscribe-kind.js +144 -0
  31. package/dist/repo-store/types.d.ts +571 -0
  32. package/dist/repo-store/types.js +42 -0
  33. package/dist/session-service.d.ts +416 -0
  34. package/dist/session-service.js +1191 -0
  35. package/dist/sidecar-allocation/contracts.d.ts +78 -0
  36. package/dist/sidecar-allocation/contracts.js +21 -0
  37. package/dist/sidecar-allocation/index.d.ts +4 -0
  38. package/dist/sidecar-allocation/index.js +3 -0
  39. package/dist/sidecar-allocation/placement-policy.d.ts +11 -0
  40. package/dist/sidecar-allocation/placement-policy.js +21 -0
  41. package/dist/sidecar-allocation/plugin-registry.d.ts +11 -0
  42. package/dist/sidecar-allocation/plugin-registry.js +37 -0
  43. package/dist/sidecar-allocation/reconciler.d.ts +42 -0
  44. package/dist/sidecar-allocation/reconciler.js +431 -0
  45. package/dist/skill-kind.d.ts +41 -0
  46. package/dist/skill-kind.js +292 -0
  47. package/dist/substrate.d.ts +8 -0
  48. package/dist/substrate.js +21 -0
  49. package/dist/workflow-allocation-service.d.ts +58 -0
  50. package/dist/workflow-allocation-service.js +239 -0
  51. package/dist/workflow-closure-resolution.d.ts +106 -0
  52. package/dist/workflow-closure-resolution.js +123 -0
  53. package/dist/workflow-definition-ensure.d.ts +24 -0
  54. package/dist/workflow-definition-ensure.js +75 -0
  55. package/dist/workflow-dispatch-service.d.ts +40 -0
  56. package/dist/workflow-dispatch-service.js +146 -0
  57. package/dist/workflow-dispatch-settlement.d.ts +29 -0
  58. package/dist/workflow-dispatch-settlement.js +140 -0
  59. package/dist/workflow-kind.d.ts +37 -0
  60. package/dist/workflow-kind.js +310 -0
  61. package/dist/workflow-probe-gate.d.ts +214 -0
  62. package/dist/workflow-probe-gate.js +207 -0
  63. package/dist/workflow-run-event-log.d.ts +21 -0
  64. package/dist/workflow-run-event-log.js +51 -0
  65. package/dist/workflow-run-kind.d.ts +440 -0
  66. package/dist/workflow-run-kind.js +2916 -0
  67. package/dist/workflow-run-reader.d.ts +47 -0
  68. package/dist/workflow-run-reader.js +153 -0
  69. package/dist/workflow-run-restore.d.ts +15 -0
  70. package/dist/workflow-run-restore.js +26 -0
  71. package/dist/workflow-source-closure.d.ts +35 -0
  72. package/dist/workflow-source-closure.js +342 -0
  73. package/dist/ws/index.d.ts +3 -0
  74. package/dist/ws/index.js +3 -0
  75. package/dist/ws/sidecar-events.d.ts +222 -0
  76. package/dist/ws/sidecar-events.js +72 -0
  77. package/dist/ws/sidecar-handler.d.ts +305 -0
  78. package/dist/ws/sidecar-handler.js +2537 -0
  79. package/dist/ws/sidecar-token-authenticator.d.ts +17 -0
  80. package/dist/ws/sidecar-token-authenticator.js +81 -0
  81. package/package.json +35 -12
  82. package/src/agent-repo.test.ts +0 -310
  83. package/src/agent-repo.ts +0 -165
  84. package/src/agent-state-kind.test.ts +0 -247
  85. package/src/agent-state-kind.ts +0 -204
  86. package/src/asset-service.test.ts +0 -540
  87. package/src/asset-service.ts +0 -378
  88. package/src/available-skills-stanza.test.ts +0 -87
  89. package/src/available-skills-stanza.ts +0 -47
  90. package/src/credential-push.ts +0 -65
  91. package/src/event-collector-registry.test.ts +0 -73
  92. package/src/event-collector-registry.ts +0 -171
  93. package/src/event-collector.test.ts +0 -1387
  94. package/src/event-collector.ts +0 -424
  95. package/src/hub-session-lookups.ts +0 -206
  96. package/src/hub-session-orchestrator.test.ts +0 -510
  97. package/src/hub-session-orchestrator.ts +0 -213
  98. package/src/index.ts +0 -78
  99. package/src/repo-store/index.ts +0 -15
  100. package/src/repo-store/store.test.ts +0 -1169
  101. package/src/repo-store/store.ts +0 -428
  102. package/src/repo-store/types.ts +0 -253
  103. package/src/session-service.test.ts +0 -895
  104. package/src/session-service.ts +0 -464
  105. package/src/skill-kind.test.ts +0 -599
  106. package/src/skill-kind.ts +0 -350
  107. package/src/ws/index.ts +0 -18
  108. package/src/ws/sidecar-events.test.ts +0 -96
  109. package/src/ws/sidecar-events.ts +0 -231
  110. package/src/ws/sidecar-handler.test.ts +0 -2217
  111. package/src/ws/sidecar-handler.ts +0 -1574
  112. package/tsconfig.json +0 -4
  113. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,41 @@
1
+ import { type AuthorizeFn, type KindHandler } from "./repo-store/index.js";
2
+ export type SkillHubPrincipal = {
3
+ readonly kind: "hub";
4
+ };
5
+ export type SkillSidecarPrincipal = {
6
+ readonly kind: "sidecar";
7
+ readonly agentId: string;
8
+ };
9
+ export type SkillPrincipal = SkillHubPrincipal | SkillSidecarPrincipal;
10
+ /**
11
+ * arktype schema for the SKILL.md frontmatter. Required fields are
12
+ * `name` and `description`; the Claude Code superset of optional fields
13
+ * (`when_to_use`, `allowed-tools`, `paths`, `model`, ...) is accepted
14
+ * via `onUndeclaredKey("ignore")` but not enforced.
15
+ *
16
+ * The forbidden-name narrow rejects `"anthropic"` and `"claude"`
17
+ * because those values are reserved by the upstream agentskills.io
18
+ * spec for vendor-owned skill packs.
19
+ */
20
+ export declare const skillFrontmatterSchema: import("arktype/internal/variants/object.ts").ObjectType<{
21
+ name: string;
22
+ description: string;
23
+ }, {}>;
24
+ export type SkillFrontmatter = typeof skillFrontmatterSchema.infer;
25
+ export type SkillIndexEntry = {
26
+ /** Equal to the containing directory name and to `frontmatter.name`. */
27
+ name: string;
28
+ description: string;
29
+ /** Full parsed frontmatter, including any accepted optional fields. */
30
+ frontmatter: Record<string, unknown>;
31
+ /** Path of the skill subdirectory relative to the asset mount, with trailing slash. */
32
+ workspaceSubpath: string;
33
+ };
34
+ /**
35
+ * Returns the parsed skill index for `(assetId, ref)`, or an empty
36
+ * array if no index has been populated yet. The index is refreshed by
37
+ * the kind handler's `onRefUpdated` hook after each successful write.
38
+ */
39
+ export declare function getSkillIndex(assetId: string, ref: string): SkillIndexEntry[];
40
+ export declare const skillKindHandler: KindHandler;
41
+ export declare const skillAuthorize: AuthorizeFn;
@@ -0,0 +1,292 @@
1
+ import { type } from "arktype";
2
+ import { getLogger } from "@intx/log";
3
+ import { glob, repoActionToGrantVerb } from "@intx/hub-common";
4
+ import { UserPrincipal, } from "./repo-store/index.js";
5
+ const logger = getLogger(["hub-sessions", "skill-kind"]);
6
+ /**
7
+ * arktype schema for the SKILL.md frontmatter. Required fields are
8
+ * `name` and `description`; the Claude Code superset of optional fields
9
+ * (`when_to_use`, `allowed-tools`, `paths`, `model`, ...) is accepted
10
+ * via `onUndeclaredKey("ignore")` but not enforced.
11
+ *
12
+ * The forbidden-name narrow rejects `"anthropic"` and `"claude"`
13
+ * because those values are reserved by the upstream agentskills.io
14
+ * spec for vendor-owned skill packs.
15
+ */
16
+ export const skillFrontmatterSchema = type({
17
+ name: type(/^[a-z0-9]+(-[a-z0-9]+)*$/)
18
+ .and("string<=64")
19
+ .narrow((n, ctx) => {
20
+ if (n === "anthropic" || n === "claude") {
21
+ return ctx.mustBe(`not the reserved name "anthropic" or "claude"`);
22
+ }
23
+ return true;
24
+ }),
25
+ description: type("1 <= string <= 1024").and(type(/^(?!.*<[^>]+>).*$/s)),
26
+ }).onUndeclaredKey("ignore");
27
+ function cacheKey(assetId, ref) {
28
+ return `${assetId}\u0000${ref}`;
29
+ }
30
+ const skillIndex = new Map();
31
+ const pendingIndex = new Map();
32
+ /**
33
+ * Returns the parsed skill index for `(assetId, ref)`, or an empty
34
+ * array if no index has been populated yet. The index is refreshed by
35
+ * the kind handler's `onRefUpdated` hook after each successful write.
36
+ */
37
+ export function getSkillIndex(assetId, ref) {
38
+ return skillIndex.get(cacheKey(assetId, ref)) ?? [];
39
+ }
40
+ const FRONTMATTER_DELIMITER = "---";
41
+ const YamlMapping = type("Record<string, unknown>");
42
+ function parseSkillMd(body) {
43
+ const lines = body.split(/\r?\n/);
44
+ if (lines[0] !== FRONTMATTER_DELIMITER) {
45
+ throw new Error("SKILL.md is missing YAML frontmatter delimiter");
46
+ }
47
+ let endIdx = -1;
48
+ for (let i = 1; i < lines.length; i += 1) {
49
+ if (lines[i] === FRONTMATTER_DELIMITER) {
50
+ endIdx = i;
51
+ break;
52
+ }
53
+ }
54
+ if (endIdx === -1) {
55
+ throw new Error("SKILL.md frontmatter has no closing delimiter");
56
+ }
57
+ const yamlText = lines.slice(1, endIdx).join("\n");
58
+ const parsed = Bun.YAML.parse(yamlText);
59
+ const validated = YamlMapping(parsed);
60
+ if (validated instanceof type.errors) {
61
+ throw new Error(`SKILL.md frontmatter must be a YAML mapping: ${validated.summary}`);
62
+ }
63
+ return { frontmatter: validated };
64
+ }
65
+ async function parseSkillEntry(subdir, readBlob) {
66
+ const skillPath = `${subdir}/SKILL.md`;
67
+ let raw;
68
+ try {
69
+ raw = await readBlob(skillPath);
70
+ }
71
+ catch (cause) {
72
+ return {
73
+ ok: false,
74
+ reason: `skill ${subdir} is missing SKILL.md: ${cause instanceof Error ? cause.message : String(cause)}`,
75
+ };
76
+ }
77
+ const body = new TextDecoder().decode(raw);
78
+ let parsed;
79
+ try {
80
+ parsed = parseSkillMd(body);
81
+ }
82
+ catch (cause) {
83
+ return {
84
+ ok: false,
85
+ reason: `skill ${subdir} frontmatter parse failed: ${cause instanceof Error ? cause.message : String(cause)}`,
86
+ };
87
+ }
88
+ const result = skillFrontmatterSchema(parsed.frontmatter);
89
+ if (result instanceof type.errors) {
90
+ return {
91
+ ok: false,
92
+ reason: `skill ${subdir} frontmatter is invalid: ${result.summary}`,
93
+ };
94
+ }
95
+ if (result.name !== subdir) {
96
+ return {
97
+ ok: false,
98
+ reason: `skill ${subdir} frontmatter.name ${JSON.stringify(result.name)} does not match directory name`,
99
+ };
100
+ }
101
+ return {
102
+ ok: true,
103
+ entry: {
104
+ name: result.name,
105
+ description: result.description,
106
+ frontmatter: parsed.frontmatter,
107
+ workspaceSubpath: `${subdir}/`,
108
+ },
109
+ };
110
+ }
111
+ async function buildSkillIndex(topLevelTreePaths, readBlob, listDir) {
112
+ const entries = [];
113
+ // Sort so the index ordering is deterministic across reads.
114
+ const candidates = [...topLevelTreePaths].sort();
115
+ for (const candidate of candidates) {
116
+ // Skill subdirectories are arbitrarily named (`<skill-name>/`), so
117
+ // there is no enumerable allowlist of names the way the workflow,
118
+ // package-registry, and agent-state handlers use. The structural
119
+ // distinction is tree-vs-blob: a top-level tree entry is a skill
120
+ // subdir, a top-level blob (e.g. `.gitignore` seeded by the
121
+ // genesis init) is not.
122
+ //
123
+ // Probe via `listDir`. The substrate's two implementations
124
+ // disagree on the not-a-tree signal — the receivePack closures
125
+ // throw, the writeTree closures return an empty array — so this
126
+ // probe accepts either signal as "not a directory" and skips the
127
+ // entry. Git does not store empty trees, so an empty `listDir`
128
+ // result is equivalent to "this name is not a directory in the
129
+ // prospective tree".
130
+ let children;
131
+ try {
132
+ children = await listDir(candidate);
133
+ }
134
+ catch {
135
+ continue;
136
+ }
137
+ if (children.length === 0)
138
+ continue;
139
+ const outcome = await parseSkillEntry(candidate, readBlob);
140
+ if (!outcome.ok) {
141
+ return { ok: false, reason: outcome.reason };
142
+ }
143
+ entries.push(outcome.entry);
144
+ }
145
+ return { ok: true, entries };
146
+ }
147
+ export const skillKindHandler = {
148
+ kind: "skill",
149
+ directoryPrefix: "assets/skill",
150
+ async validatePush({ repoId, ref, topLevelTreePaths, readBlob, listDir, }) {
151
+ // Drop any staged entry from a previous attempt first. The
152
+ // substrate calls validatePush before advancing the ref, so a prior
153
+ // validation that was accepted but never followed by onRefUpdated
154
+ // (e.g. the commit step threw after validation succeeded) would
155
+ // otherwise leave a stale entry that a later rejected attempt
156
+ // would silently inherit.
157
+ const key = cacheKey(repoId.id, ref);
158
+ pendingIndex.delete(key);
159
+ const result = await buildSkillIndex(topLevelTreePaths, readBlob, listDir);
160
+ if (!result.ok) {
161
+ logger.debug `skill validatePush rejected ${repoId.kind}/${repoId.id} on ${ref}: ${result.reason}`;
162
+ return { ok: false, reason: result.reason };
163
+ }
164
+ // Stage the parsed index against (repoId.id, ref). The substrate
165
+ // calls onRefUpdated immediately after the ref is advanced and we
166
+ // promote the staged entry into the live cache there.
167
+ pendingIndex.set(key, result.entries);
168
+ return { ok: true };
169
+ },
170
+ onRefUpdated({ repoId, ref }) {
171
+ const key = cacheKey(repoId.id, ref);
172
+ const staged = pendingIndex.get(key);
173
+ if (staged === undefined) {
174
+ throw new Error(`skillKindHandler.onRefUpdated: no validated tree pending for ${repoId.id} @ ${ref}`);
175
+ }
176
+ pendingIndex.delete(key);
177
+ skillIndex.set(key, staged);
178
+ },
179
+ };
180
+ const SidecarPrincipal = type({
181
+ kind: "'sidecar'",
182
+ agentId: "string",
183
+ });
184
+ export const skillAuthorize = (principal, repoId, ref, action) => {
185
+ if (repoId.kind !== "skill") {
186
+ return {
187
+ allowed: false,
188
+ reason: `skill authorize received non-skill repo ${repoId.kind}/${repoId.id}`,
189
+ };
190
+ }
191
+ if (principal.kind === "hub") {
192
+ return { allowed: true };
193
+ }
194
+ if (principal.kind === "sidecar") {
195
+ const parsed = SidecarPrincipal(principal);
196
+ if (parsed instanceof type.errors) {
197
+ return {
198
+ allowed: false,
199
+ reason: `sidecar principal is malformed: ${parsed.summary}`,
200
+ };
201
+ }
202
+ switch (action) {
203
+ case "createPack":
204
+ case "resolveRef":
205
+ return { allowed: true };
206
+ case "init":
207
+ case "writeTree":
208
+ case "receivePack":
209
+ return {
210
+ allowed: false,
211
+ reason: `sidecars may only read skill assets, not ${action}`,
212
+ };
213
+ default: {
214
+ const _exhaustive = action;
215
+ return {
216
+ allowed: false,
217
+ reason: `unhandled action: ${String(_exhaustive)}`,
218
+ };
219
+ }
220
+ }
221
+ }
222
+ if (principal.kind === "user") {
223
+ // The route layer has already pre-resolved the grant verdict and
224
+ // attached it as `authz`. The substrate does NOT re-query the
225
+ // grant store here; it (a) checks the bearer-token's claims
226
+ // bound the requested (ref, action) and have not expired, and
227
+ // (b) sanity-checks that the pre-resolved verdict targets this
228
+ // exact resource and grant verb. Both gates must pass before the
229
+ // verdict's `effect` is honoured.
230
+ const parsed = UserPrincipal(principal);
231
+ if (parsed instanceof type.errors) {
232
+ return {
233
+ allowed: false,
234
+ reason: `user principal is malformed: ${parsed.summary}`,
235
+ };
236
+ }
237
+ if (!parsed.tokenClaims.actions.includes(action)) {
238
+ return {
239
+ allowed: false,
240
+ reason: `token does not grant action ${action}`,
241
+ };
242
+ }
243
+ // `ref === "*"` is the substrate's sentinel for a bulk read used
244
+ // by `listRefs`. The advertise-refs layer applies the per-ref
245
+ // refPattern filter on the listing it returns, so gating the bulk
246
+ // read on refPattern here would prevent any token with a
247
+ // narrow-pattern (e.g. `refs/heads/main`) from listing refs at
248
+ // all. Only the action and expiry are enforced for the bulk read;
249
+ // every other call site passes a concrete ref and is gated below.
250
+ if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
251
+ return {
252
+ allowed: false,
253
+ reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
254
+ };
255
+ }
256
+ if (Date.now() >= parsed.tokenClaims.expiresAt) {
257
+ return {
258
+ allowed: false,
259
+ reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
260
+ };
261
+ }
262
+ const expectedResource = `asset:${repoId.id}`;
263
+ if (parsed.authz.resource !== expectedResource) {
264
+ return {
265
+ allowed: false,
266
+ reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
267
+ };
268
+ }
269
+ const expectedGrantVerb = repoActionToGrantVerb(action);
270
+ if (parsed.authz.grantVerb !== expectedGrantVerb) {
271
+ return {
272
+ allowed: false,
273
+ reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
274
+ };
275
+ }
276
+ if (parsed.authz.effect === "allow") {
277
+ return { allowed: true };
278
+ }
279
+ return {
280
+ allowed: false,
281
+ reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
282
+ };
283
+ }
284
+ // Fail closed on any kind not handled above. The tenant-level
285
+ // `workflow` principal kind (`@intx/types` principalKinds) is a
286
+ // grant owner, not a skill bearer, and never carries a skill repo
287
+ // push here -- so it is intentionally left denied.
288
+ return {
289
+ allowed: false,
290
+ reason: `unknown principal kind: ${principal.kind}`,
291
+ };
292
+ };
@@ -0,0 +1,8 @@
1
+ export { DEFAULT_CONSUMED_RETENTION_MS, dequeueToProcessing, enqueueInbox, markConsumed, parseEventSeq, readOwnedMessageIds, readCommittedWorkflowRunLifecycle, readWorkflowRunLifecycle, readProcessingEntry, replayProcessingToInbox, requireEventSeq, StaleInboxEnqueueError, WORKFLOW_RUN_AGENT_STATE_PREFIX, WORKFLOW_RUN_EVENTS_DIR, WORKFLOW_RUN_RUNS_PREFIX, } from "./workflow-run-kind.js";
2
+ export type { WorkflowRunSupervisorPrincipal, WorkflowRunWorkflowProcessPrincipal, DequeueToProcessingResult, EnqueueAlreadyPresentReason, EnqueueInboxArgs, EnqueueInboxOutcome, EnqueueInboxResult, MarkConsumedArgs, MarkConsumedResult, WorkflowRunLifecycle, ReplayProcessingToInboxOpts, ReplayProcessingToInboxResult, } from "./workflow-run-kind.js";
3
+ export { encodeCombinedEventLog, splitCombinedEventLog, WORKFLOW_RUN_EVENTS_FILE, } from "./workflow-run-event-log.js";
4
+ export { workflowDefinitionEnvelopeSchema } from "./workflow-kind.js";
5
+ export { subscribeKind } from "./repo-store/subscribe-kind.js";
6
+ export type { SubscribeKindEntry } from "./repo-store/subscribe-kind.js";
7
+ export { createAgentRepoStore } from "./agent-repo.js";
8
+ export type { CommittedReads, CommittedTreeEntry, Principal, RepoId, RepoStore, WriteResult, InitRepoOpts, NewlyTerminalRun, } from "./repo-store/types.js";
@@ -0,0 +1,21 @@
1
+ // The db-free substrate layer of this package.
2
+ //
3
+ // `@intx/hub-sessions` holds two layers: the git-backed repo-store substrate
4
+ // (the `RepoStore`, the workflow-run claim-check and event-log primitives, the
5
+ // kind-handler protocol) and the db-backed control-plane services (sessions,
6
+ // credential push, the websocket layer). The substrate layer never imports
7
+ // `@intx/db`; the services layer does. The package barrel (`index.ts`)
8
+ // re-exports both, so importing any symbol through it pulls drizzle and the
9
+ // rest of the hub data layer into module-evaluation.
10
+ //
11
+ // This entry exposes only the substrate layer, so any consumer that needs
12
+ // repo/substrate access without the control-plane services -- for example a
13
+ // process that reads and writes the agent-state and workflow-run repos but
14
+ // runs nowhere near a database -- can import it without dragging `@intx/db`
15
+ // into its boot graph. Every symbol re-exported here is defined in a db-free
16
+ // module. The barrel still re-exports all of these for hub-side consumers.
17
+ export { DEFAULT_CONSUMED_RETENTION_MS, dequeueToProcessing, enqueueInbox, markConsumed, parseEventSeq, readOwnedMessageIds, readCommittedWorkflowRunLifecycle, readWorkflowRunLifecycle, readProcessingEntry, replayProcessingToInbox, requireEventSeq, StaleInboxEnqueueError, WORKFLOW_RUN_AGENT_STATE_PREFIX, WORKFLOW_RUN_EVENTS_DIR, WORKFLOW_RUN_RUNS_PREFIX, } from "./workflow-run-kind.js";
18
+ export { encodeCombinedEventLog, splitCombinedEventLog, WORKFLOW_RUN_EVENTS_FILE, } from "./workflow-run-event-log.js";
19
+ export { workflowDefinitionEnvelopeSchema } from "./workflow-kind.js";
20
+ export { subscribeKind } from "./repo-store/subscribe-kind.js";
21
+ export { createAgentRepoStore } from "./agent-repo.js";
@@ -0,0 +1,58 @@
1
+ import { type DB, type SidecarAllocation } from "@intx/db";
2
+ import { type CredentialCipher, type SidecarPlacementRequirement } from "@intx/types";
3
+ import type { ToolPackagePin } from "@intx/types/tool-packages";
4
+ import type { WorkflowDefinitionSource } from "@intx/types/workflow-sources";
5
+ import type { DeployContent } from "./agent-repo.js";
6
+ import { type SidecarPluginRegistry } from "./sidecar-allocation/index.js";
7
+ import { type DeployWorkflowDefinitionResult, type PreparedWorkflowDeployer } from "./session-service.js";
8
+ import type { SidecarAllocationRouter } from "./ws/sidecar-handler.js";
9
+ export declare class ExclusiveWorkflowPlacementError extends Error {
10
+ readonly code: string;
11
+ constructor(code: string, message: string);
12
+ }
13
+ export type PrepareExclusiveWorkflowDeploymentArgs = {
14
+ readonly tenantId: string;
15
+ readonly anchorRunId: string;
16
+ readonly deploymentDomain: string;
17
+ /** Where the definition's bytes come from at probe time. */
18
+ readonly source: WorkflowDefinitionSource;
19
+ /** The `interchange.workflow` entry-module path the sidecar evaluates. */
20
+ readonly entry: string;
21
+ /**
22
+ * A `name@range` spec for the definition package. REQUIRED for the `registry`
23
+ * and asset-`tarball` variants; omitted for the asset-`source` variant.
24
+ */
25
+ readonly pin?: string;
26
+ readonly definitionAssetId: string;
27
+ readonly placement: SidecarPlacementRequirement & {
28
+ readonly sharing: "exclusive";
29
+ };
30
+ readonly sessionId: string;
31
+ readonly sourceAuthorityPrincipalId: string;
32
+ readonly sourceOfferingIds: readonly string[];
33
+ readonly defaultSourceOfferingId: string;
34
+ readonly deployContent: DeployContent;
35
+ readonly toolPackagePins?: readonly ToolPackagePin[];
36
+ };
37
+ export type PreparedExclusiveWorkflowDeployment = {
38
+ readonly anchorRunId: string;
39
+ readonly deploymentAddress: string;
40
+ readonly allocationId: string;
41
+ readonly status: "pending";
42
+ };
43
+ export type WorkflowAllocationService = {
44
+ prepareExclusiveDeployment(args: PrepareExclusiveWorkflowDeploymentArgs): Promise<PreparedExclusiveWorkflowDeployment>;
45
+ deployReadyAllocation(allocation: SidecarAllocation): Promise<DeployWorkflowDefinitionResult | null>;
46
+ };
47
+ export type WorkflowAllocationServiceDeps = {
48
+ readonly db: DB["db"];
49
+ readonly plugins: SidecarPluginRegistry;
50
+ readonly preparedDeployer: PreparedWorkflowDeployer;
51
+ readonly credentialCipher?: CredentialCipher;
52
+ readonly allocationRouter: Pick<SidecarAllocationRouter, "isAllocatedWorkflowActive">;
53
+ readonly createAllocationId?: () => string;
54
+ readonly now?: () => Date;
55
+ };
56
+ /** Resolve the tenant-inherited sidecar placement into one launch decision. */
57
+ export declare function resolveWorkflowSidecarPlacement(db: DB["db"], tenantId: string): Promise<SidecarPlacementRequirement | null>;
58
+ export declare function createWorkflowAllocationService({ db, plugins, preparedDeployer, credentialCipher, allocationRouter, createAllocationId, now, }: WorkflowAllocationServiceDeps): WorkflowAllocationService;
@@ -0,0 +1,239 @@
1
+ import { createSidecarAllocationStore, createWorkflowRunLaunchSpecStore, getAncestorChain, resolveSourcesByOfferingIds, } from "@intx/db";
2
+ import { grant, tenant as tenantTable, workflowRun } from "@intx/db/schema";
3
+ import { eq, inArray } from "drizzle-orm";
4
+ import { generateId } from "@intx/hub-common";
5
+ import { hexEncode, TenantConfig, } from "@intx/types";
6
+ import { deriveRunAddress, deriveRunAgentId } from "@intx/workflow-deploy";
7
+ import { resolveEffectiveSidecarPlacement, } from "./sidecar-allocation/index.js";
8
+ import { SessionLaunchError, } from "./session-service.js";
9
+ export class ExclusiveWorkflowPlacementError extends Error {
10
+ code;
11
+ constructor(code, message) {
12
+ super(message);
13
+ this.name = "ExclusiveWorkflowPlacementError";
14
+ this.code = code;
15
+ }
16
+ }
17
+ function randomAllocationId() {
18
+ return `sal_${hexEncode(crypto.getRandomValues(new Uint8Array(16)))}`;
19
+ }
20
+ /** Resolve the tenant-inherited sidecar placement into one launch decision. */
21
+ export async function resolveWorkflowSidecarPlacement(db, tenantId) {
22
+ const tenantIds = await getAncestorChain(db, tenantId);
23
+ const rows = await db.query.tenant.findMany({
24
+ where: inArray(tenantTable.id, tenantIds),
25
+ columns: { id: true, config: true },
26
+ });
27
+ const configsByTenantId = new Map(rows.map((row) => [
28
+ row.id,
29
+ row.config === null
30
+ ? TenantConfig.assert({})
31
+ : TenantConfig.assert(row.config),
32
+ ]));
33
+ const tenantConfigs = tenantIds.map((id) => {
34
+ const config = configsByTenantId.get(id);
35
+ if (config === undefined) {
36
+ throw new Error(`Tenant ${id} disappeared while resolving sidecar placement`);
37
+ }
38
+ return config;
39
+ });
40
+ return resolveEffectiveSidecarPlacement({ tenantConfigs });
41
+ }
42
+ export function createWorkflowAllocationService({ db, plugins, preparedDeployer, credentialCipher, allocationRouter, createAllocationId = randomAllocationId, now = () => new Date(), }) {
43
+ const allocationStore = createSidecarAllocationStore(db);
44
+ const launchSpecStore = createWorkflowRunLaunchSpecStore(db);
45
+ async function prepareExclusiveDeployment(args) {
46
+ // Fail closed before any probe: exclusive placement has no meaning without a
47
+ // provisioner to stand up its dedicated sidecar, and the shared-capacity
48
+ // probe below is wasted work if no provisioner exists. (In-tree this is
49
+ // always null -- exclusive is dormant -- so a live prepare never runs here.)
50
+ const provisioner = plugins.getDefaultProvisioner();
51
+ if (provisioner === null) {
52
+ throw new ExclusiveWorkflowPlacementError("exclusive_provisioner_unavailable", "No default sidecar provisioner is configured for exclusive workflows");
53
+ }
54
+ if (args.sourceOfferingIds.length === 0) {
55
+ throw new ExclusiveWorkflowPlacementError("invalid_source_offerings", "Exclusive workflow deployment requires at least one catalog offering");
56
+ }
57
+ if (!args.sourceOfferingIds.includes(args.defaultSourceOfferingId)) {
58
+ throw new ExclusiveWorkflowPlacementError("invalid_source_offerings", "The default source offering must be present in the source offering chain");
59
+ }
60
+ const sourceCheck = await resolveSourcesByOfferingIds(db, args.tenantId, args.sourceOfferingIds, credentialCipher);
61
+ if (!sourceCheck.ok) {
62
+ throw new ExclusiveWorkflowPlacementError("source_offering_unavailable", `Catalog offering ${sourceCheck.offeringId} cannot be used by the deployment authority`);
63
+ }
64
+ // Probe + gate + freeze the code-sourced definition ONCE, on shared
65
+ // capacity, at request time. The freeze is sidecar-agnostic (a wire hash
66
+ // over the inert projection plus the resolved closure), so the frozen bundle
67
+ // deploys later to the dedicated allocation with no re-probe. A non-approval
68
+ // surfaces as a `WorkflowDefinitionInvalidError` from the deployer, which the
69
+ // route maps to a 409.
70
+ const approved = await preparedDeployer.installAndApproveWorkflowSource({
71
+ source: args.source,
72
+ entry: args.entry,
73
+ ...(args.pin !== undefined ? { pin: args.pin } : {}),
74
+ definitionAssetId: args.definitionAssetId,
75
+ });
76
+ if (!approved.approval.ok) {
77
+ // `installAndApproveWorkflowSource` already fails closed on a non-approval;
78
+ // restate the narrowing so the frozen bundle below reads the ok arm.
79
+ throw new Error("prepareExclusiveDeployment: install did not yield an approved definition");
80
+ }
81
+ // Capture the narrowed values before the transaction: TS drops the
82
+ // `approval.ok` narrowing inside the async callback below.
83
+ const definitionId = approved.approval.definitionId;
84
+ const frozenApprovalBundle = {
85
+ source: args.source,
86
+ entry: args.entry,
87
+ projection: approved.projection,
88
+ closure: approved.closure,
89
+ approvedWireHash: approved.approval.approvedWireHash,
90
+ approvedGrants: [...approved.approval.approvedGrants],
91
+ };
92
+ const allocationId = createAllocationId();
93
+ const createdAt = now();
94
+ const deploymentAddress = deriveRunAddress({
95
+ runId: args.anchorRunId,
96
+ domain: args.deploymentDomain,
97
+ });
98
+ await db.transaction(async (tx) => {
99
+ // The freeze already ensured (create-if-absent) the definition row keyed by
100
+ // the approved wire hash and returned its id; anchor to THAT row rather
101
+ // than re-ensuring, so the anchor's definition is exactly the one approved.
102
+ await tx.insert(workflowRun).values({
103
+ id: args.anchorRunId,
104
+ tenantId: args.tenantId,
105
+ anchorRunId: args.anchorRunId,
106
+ definitionId,
107
+ address: deploymentAddress,
108
+ // Born "deployed": the anchor is live but pre-trigger. The first trigger
109
+ // flips it to "running" (see `anchorWithPrincipal`).
110
+ status: "deployed",
111
+ createdAt,
112
+ });
113
+ await tx.insert(grant).values({
114
+ id: generateId("grant"),
115
+ tenantId: args.tenantId,
116
+ principalId: args.sourceAuthorityPrincipalId,
117
+ resource: `workflow-run:${args.anchorRunId}`,
118
+ action: "read",
119
+ effect: "allow",
120
+ origin: "creator",
121
+ createdAt,
122
+ updatedAt: createdAt,
123
+ });
124
+ await launchSpecStore.create({
125
+ anchorRunId: args.anchorRunId,
126
+ sessionId: args.sessionId,
127
+ deploymentDomain: args.deploymentDomain,
128
+ sourceAuthorityPrincipalId: args.sourceAuthorityPrincipalId,
129
+ frozenApprovalBundle,
130
+ sourceOfferingIds: [...args.sourceOfferingIds],
131
+ defaultSourceOfferingId: args.defaultSourceOfferingId,
132
+ deployContent: args.deployContent,
133
+ ...(args.toolPackagePins !== undefined
134
+ ? { toolPackagePins: [...args.toolPackagePins] }
135
+ : {}),
136
+ createdAt,
137
+ }, tx);
138
+ await allocationStore.createPending({
139
+ id: allocationId,
140
+ anchorRunId: args.anchorRunId,
141
+ tenantId: args.tenantId,
142
+ provisionerId: provisioner.id,
143
+ provisionerApiVersion: provisioner.apiVersion,
144
+ provisionerBindingFingerprint: provisioner.bindingFingerprint,
145
+ placement: args.placement,
146
+ now: createdAt,
147
+ }, tx);
148
+ });
149
+ return {
150
+ anchorRunId: args.anchorRunId,
151
+ deploymentAddress,
152
+ allocationId,
153
+ status: "pending",
154
+ };
155
+ }
156
+ async function deployReadyAllocation(allocation) {
157
+ if (allocation.status !== "allocated" ||
158
+ allocation.ensureAcceptedGeneration !== allocation.generation) {
159
+ throw new Error(`Allocation ${allocation.id} generation ${String(allocation.generation)} is not accepted for deployment`);
160
+ }
161
+ const allocationTarget = {
162
+ allocationId: allocation.id,
163
+ generation: allocation.generation,
164
+ };
165
+ const anchor = await db.query.workflowRun.findFirst({
166
+ where: eq(workflowRun.id, allocation.anchorRunId),
167
+ columns: { publicKey: true, definitionId: true },
168
+ });
169
+ if (anchor === undefined) {
170
+ throw new Error(`Allocation ${allocation.id} has no workflow anchor run`);
171
+ }
172
+ if (await allocationRouter.isAllocatedWorkflowActive(allocationTarget)) {
173
+ if (anchor.publicKey !== null)
174
+ return null;
175
+ throw new SessionLaunchError("provision", new Error(`Allocation ${allocation.id} has an active workflow without a completed initialization key`), true);
176
+ }
177
+ if (anchor.definitionId === null) {
178
+ throw new Error(`Allocation ${allocation.id} anchor run has no frozen workflow definition`);
179
+ }
180
+ const spec = await launchSpecStore.get(allocation.anchorRunId);
181
+ if (spec === null) {
182
+ throw new Error(`Allocation ${allocation.id} has no workflow launch specification`);
183
+ }
184
+ // The frozen bundle deploys verbatim -- no re-probe. Rehydrate the approval
185
+ // hand-off from it: the approved grant set becomes a `Set`, and the frozen
186
+ // definition id is the anchor's own (set at prepare time from this freeze).
187
+ const bundle = spec.frozenApprovalBundle;
188
+ const approved = {
189
+ approval: {
190
+ ok: true,
191
+ definitionId: anchor.definitionId,
192
+ approvedWireHash: bundle.approvedWireHash,
193
+ approvedGrants: new Set(bundle.approvedGrants),
194
+ projection: bundle.projection,
195
+ },
196
+ projection: bundle.projection,
197
+ closure: bundle.closure,
198
+ };
199
+ // Re-resolve the inference chain from the catalog at launch time -- the
200
+ // launch spec stores offering ids, never resolved sources, so a rotated
201
+ // credential is picked up here and no secret was ever persisted.
202
+ const resolved = await resolveSourcesByOfferingIds(db, allocation.tenantId, spec.sourceOfferingIds, credentialCipher);
203
+ if (!resolved.ok) {
204
+ throw new Error(`Catalog offering ${resolved.offeringId} is unavailable while recovering allocation ${allocation.id}`);
205
+ }
206
+ const defaultSource = resolved.sources.find((source) => source.id === spec.defaultSourceOfferingId);
207
+ if (defaultSource === undefined) {
208
+ throw new Error(`Default offering ${spec.defaultSourceOfferingId} was not resolved for allocation ${allocation.id}`);
209
+ }
210
+ const deploymentAddress = deriveRunAddress({
211
+ runId: allocation.anchorRunId,
212
+ domain: spec.deploymentDomain,
213
+ });
214
+ const config = {
215
+ sessionId: spec.sessionId,
216
+ agentId: deriveRunAgentId({ runId: allocation.anchorRunId }),
217
+ tenantId: allocation.tenantId,
218
+ principalId: spec.sourceAuthorityPrincipalId,
219
+ agentAddress: deploymentAddress,
220
+ systemPrompt: "",
221
+ tools: [],
222
+ grants: [],
223
+ sources: resolved.sources,
224
+ defaultSource: defaultSource.id,
225
+ };
226
+ return preparedDeployer.deployPreparedCodeSourcedWorkflow({
227
+ tenantId: allocation.tenantId,
228
+ anchorRunId: allocation.anchorRunId,
229
+ deploymentDomain: spec.deploymentDomain,
230
+ agentAddress: deploymentAddress,
231
+ source: bundle.source,
232
+ approved,
233
+ config,
234
+ allocationTarget,
235
+ ...(credentialCipher !== undefined ? { credentialCipher } : {}),
236
+ });
237
+ }
238
+ return { prepareExclusiveDeployment, deployReadyAllocation };
239
+ }