@mastra/factory 0.2.2-alpha.0 → 0.2.2-alpha.2

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 (174) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/auth.d.ts +2 -2
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +401 -314
  5. package/dist/auth.js.map +1 -1
  6. package/dist/capabilities/connection.js +1 -1
  7. package/dist/capabilities/intake.js +1 -1
  8. package/dist/capabilities/version-control.js +1 -1
  9. package/dist/factory.d.ts.map +1 -1
  10. package/dist/factory.js +441 -39455
  11. package/dist/factory.js.map +1 -1
  12. package/dist/index.js +2 -39466
  13. package/dist/integrations/base.js +1 -1
  14. package/dist/integrations/github/config.js +36 -22
  15. package/dist/integrations/github/config.js.map +1 -1
  16. package/dist/integrations/github/factory-session.js +33 -37
  17. package/dist/integrations/github/factory-session.js.map +1 -1
  18. package/dist/integrations/github/integration.js +1037 -3624
  19. package/dist/integrations/github/integration.js.map +1 -1
  20. package/dist/integrations/github/issue-triage.js +71 -71
  21. package/dist/integrations/github/issue-triage.js.map +1 -1
  22. package/dist/integrations/github/pat.js +47 -36
  23. package/dist/integrations/github/pat.js.map +1 -1
  24. package/dist/integrations/github/provenance.js +72 -101
  25. package/dist/integrations/github/provenance.js.map +1 -1
  26. package/dist/integrations/github/routes.d.ts.map +1 -1
  27. package/dist/integrations/github/routes.js +1272 -1833
  28. package/dist/integrations/github/routes.js.map +1 -1
  29. package/dist/integrations/github/rules.js +203 -471
  30. package/dist/integrations/github/rules.js.map +1 -1
  31. package/dist/integrations/github/sandbox.d.ts +2 -0
  32. package/dist/integrations/github/sandbox.d.ts.map +1 -1
  33. package/dist/integrations/github/sandbox.js +401 -326
  34. package/dist/integrations/github/sandbox.js.map +1 -1
  35. package/dist/integrations/github/session-subscriptions.js +165 -223
  36. package/dist/integrations/github/session-subscriptions.js.map +1 -1
  37. package/dist/integrations/github/subscriptions.js +43 -51
  38. package/dist/integrations/github/subscriptions.js.map +1 -1
  39. package/dist/integrations/github/token-refresh.js +15 -18
  40. package/dist/integrations/github/token-refresh.js.map +1 -1
  41. package/dist/integrations/github/webhook.js +359 -343
  42. package/dist/integrations/github/webhook.js.map +1 -1
  43. package/dist/integrations/linear/agent-tools.js +77 -100
  44. package/dist/integrations/linear/agent-tools.js.map +1 -1
  45. package/dist/integrations/linear/integration.js +611 -1309
  46. package/dist/integrations/linear/integration.js.map +1 -1
  47. package/dist/integrations/linear/routes.js +227 -230
  48. package/dist/integrations/linear/routes.js.map +1 -1
  49. package/dist/integrations/linear/rules.js +126 -346
  50. package/dist/integrations/linear/rules.js.map +1 -1
  51. package/dist/integrations/linear/storage.js +1 -1
  52. package/dist/integrations/platform/api-client.js +131 -135
  53. package/dist/integrations/platform/api-client.js.map +1 -1
  54. package/dist/integrations/platform/github/event-worker.js +275 -573
  55. package/dist/integrations/platform/github/event-worker.js.map +1 -1
  56. package/dist/integrations/platform/github/integration.d.ts.map +1 -1
  57. package/dist/integrations/platform/github/integration.js +845 -3942
  58. package/dist/integrations/platform/github/integration.js.map +1 -1
  59. package/dist/integrations/platform/linear/integration.js +457 -1309
  60. package/dist/integrations/platform/linear/integration.js.map +1 -1
  61. package/dist/integrations/workos/integration.js +80 -85
  62. package/dist/integrations/workos/integration.js.map +1 -1
  63. package/dist/routes/config.d.ts.map +1 -1
  64. package/dist/routes/config.js +933 -915
  65. package/dist/routes/config.js.map +1 -1
  66. package/dist/routes/custom-provider-source.js +110 -93
  67. package/dist/routes/custom-provider-source.js.map +1 -1
  68. package/dist/routes/fs.js +401 -349
  69. package/dist/routes/fs.js.map +1 -1
  70. package/dist/routes/intake.js +165 -165
  71. package/dist/routes/intake.js.map +1 -1
  72. package/dist/routes/oauth.js +447 -25166
  73. package/dist/routes/oauth.js.map +1 -1
  74. package/dist/routes/projects.js +437 -376
  75. package/dist/routes/projects.js.map +1 -1
  76. package/dist/routes/provider-credentials.js +76 -58
  77. package/dist/routes/provider-credentials.js.map +1 -1
  78. package/dist/routes/route.js +12 -8
  79. package/dist/routes/route.js.map +1 -1
  80. package/dist/routes/skills.js +160 -173
  81. package/dist/routes/skills.js.map +1 -1
  82. package/dist/routes/surface.js +250 -28618
  83. package/dist/routes/surface.js.map +1 -1
  84. package/dist/routes/tenant-credentials.js +141 -166
  85. package/dist/routes/tenant-credentials.js.map +1 -1
  86. package/dist/routes/work-items.js +654 -862
  87. package/dist/routes/work-items.js.map +1 -1
  88. package/dist/rules/binding-context.js +23 -35
  89. package/dist/rules/binding-context.js.map +1 -1
  90. package/dist/rules/defaults.js +200 -309
  91. package/dist/rules/defaults.js.map +1 -1
  92. package/dist/rules/dispatcher.d.ts.map +1 -1
  93. package/dist/rules/dispatcher.js +438 -677
  94. package/dist/rules/dispatcher.js.map +1 -1
  95. package/dist/rules/index.js +5 -599
  96. package/dist/rules/processor.js +344 -596
  97. package/dist/rules/processor.js.map +1 -1
  98. package/dist/rules/resolve.js +21 -18
  99. package/dist/rules/resolve.js.map +1 -1
  100. package/dist/rules/start-coordinator.js +168 -157
  101. package/dist/rules/start-coordinator.js.map +1 -1
  102. package/dist/rules/tools.js +50 -85
  103. package/dist/rules/tools.js.map +1 -1
  104. package/dist/rules/transition-service.js +183 -454
  105. package/dist/rules/transition-service.js.map +1 -1
  106. package/dist/rules/types.js +33 -29
  107. package/dist/rules/types.js.map +1 -1
  108. package/dist/rules/validation.js +266 -281
  109. package/dist/rules/validation.js.map +1 -1
  110. package/dist/sandbox/fleet.js +272 -250
  111. package/dist/sandbox/fleet.js.map +1 -1
  112. package/dist/sandbox/reattach.js +13 -6
  113. package/dist/sandbox/reattach.js.map +1 -1
  114. package/dist/server-error.js +12 -16
  115. package/dist/server-error.js.map +1 -1
  116. package/dist/skills/service.js +30 -35
  117. package/dist/skills/service.js.map +1 -1
  118. package/dist/spa-static.d.ts.map +1 -1
  119. package/dist/spa-static.js +91 -64
  120. package/dist/spa-static.js.map +1 -1
  121. package/dist/state-signing.js +73 -44
  122. package/dist/state-signing.js.map +1 -1
  123. package/dist/storage/domains/audit/agent-audit.js +60 -59
  124. package/dist/storage/domains/audit/agent-audit.js.map +1 -1
  125. package/dist/storage/domains/audit/base.js +169 -112
  126. package/dist/storage/domains/audit/base.js.map +1 -1
  127. package/dist/storage/domains/audit/domain.js +155 -153
  128. package/dist/storage/domains/audit/domain.js.map +1 -1
  129. package/dist/storage/domains/credentials/base.js +259 -243
  130. package/dist/storage/domains/credentials/base.js.map +1 -1
  131. package/dist/storage/domains/custom-providers/base.js +138 -143
  132. package/dist/storage/domains/custom-providers/base.js.map +1 -1
  133. package/dist/storage/domains/intake/base.js +75 -57
  134. package/dist/storage/domains/intake/base.js.map +1 -1
  135. package/dist/storage/domains/integrations/base.js +310 -245
  136. package/dist/storage/domains/integrations/base.js.map +1 -1
  137. package/dist/storage/domains/memory-settings/base.js +117 -102
  138. package/dist/storage/domains/memory-settings/base.js.map +1 -1
  139. package/dist/storage/domains/model-packs/base.js +89 -88
  140. package/dist/storage/domains/model-packs/base.js.map +1 -1
  141. package/dist/storage/domains/projects/base.js +97 -92
  142. package/dist/storage/domains/projects/base.js.map +1 -1
  143. package/dist/storage/domains/queue-health/base.js +111 -76
  144. package/dist/storage/domains/queue-health/base.js.map +1 -1
  145. package/dist/storage/domains/source-control/base.d.ts +9 -0
  146. package/dist/storage/domains/source-control/base.d.ts.map +1 -1
  147. package/dist/storage/domains/source-control/base.js +777 -702
  148. package/dist/storage/domains/source-control/base.js.map +1 -1
  149. package/dist/storage/domains/source-control/inmemory.d.ts +4 -0
  150. package/dist/storage/domains/source-control/inmemory.d.ts.map +1 -1
  151. package/dist/storage/domains/source-control/inmemory.js +322 -344
  152. package/dist/storage/domains/source-control/inmemory.js.map +1 -1
  153. package/dist/storage/domains/work-items/base.d.ts.map +1 -1
  154. package/dist/storage/domains/work-items/base.js +1266 -1132
  155. package/dist/storage/domains/work-items/base.js.map +1 -1
  156. package/dist/storage/domains/work-items/metrics.js +202 -184
  157. package/dist/storage/domains/work-items/metrics.js.map +1 -1
  158. package/dist/timing.d.ts +15 -0
  159. package/dist/timing.d.ts.map +1 -0
  160. package/dist/timing.js +34 -0
  161. package/dist/timing.js.map +1 -0
  162. package/dist/workspace.d.ts +2 -3
  163. package/dist/workspace.d.ts.map +1 -1
  164. package/dist/workspace.js +236 -438
  165. package/dist/workspace.js.map +1 -1
  166. package/factory-skills/factory-review/SKILL.md +9 -2
  167. package/package.json +9 -9
  168. package/dist/capabilities/connection.js.map +0 -1
  169. package/dist/capabilities/intake.js.map +0 -1
  170. package/dist/capabilities/version-control.js.map +0 -1
  171. package/dist/index.js.map +0 -1
  172. package/dist/integrations/base.js.map +0 -1
  173. package/dist/integrations/linear/storage.js.map +0 -1
  174. package/dist/rules/index.js.map +0 -1
@@ -1,903 +1,695 @@
1
- // src/routes/work-items.ts
1
+ import { FACTORY_RULE_BOARDS, FACTORY_RULE_STAGES } from "../rules/types.js";
2
+ import { Route } from "./route.js";
3
+ import { FactoryStartTransitionError } from "../rules/start-coordinator.js";
4
+ import { thresholdsOrDefault } from "../storage/domains/queue-health/base.js";
5
+ import { WorkItemRelationError } from "../storage/domains/work-items/base.js";
6
+ import { computeFactoryMetrics, parseMetricsRange } from "../storage/domains/work-items/metrics.js";
2
7
  import { registerApiRoute } from "@mastra/core/server";
3
-
4
- // src/rules/start-coordinator.ts
5
- import { RequestContext } from "@mastra/core/request-context";
6
- import { formatSkillActivation } from "@mastra/core/workspace";
7
- var FactoryStartTransitionError = class extends Error {
8
- result;
9
- constructor(result) {
10
- super(result.reason);
11
- this.name = "FactoryStartTransitionError";
12
- this.result = result;
13
- }
14
- };
15
-
16
- // src/rules/types.ts
17
- var FACTORY_RULE_STAGES = ["intake", "triage", "planning", "execute", "review", "done", "canceled"];
18
- var FACTORY_RULE_BOARDS = ["work", "review"];
19
-
20
- // src/storage/domains/queue-health/base.ts
21
- import { FactoryStorageDomain, UniqueViolationError } from "@mastra/core/storage";
22
- var DEFAULT_QUEUE_HEALTH_CONFIG = {
23
- thresholdsSeconds: [14400, 86400, 259200]
24
- };
25
- function assertValidThresholds(config) {
26
- const t = config.thresholdsSeconds;
27
- if (!Array.isArray(t) || t.length === 0 || t.some((v) => typeof v !== "number" || !Number.isFinite(v))) {
28
- throw new Error("[QueueHealthStorage] thresholdsSeconds must be a non-empty array of finite numbers.");
29
- }
30
- for (let i = 1; i < t.length; i++) {
31
- if (t[i] <= t[i - 1]) {
32
- throw new Error("[QueueHealthStorage] thresholdsSeconds must be strictly ascending.");
33
- }
34
- }
35
- }
36
- function parseQueueHealthConfig(body) {
37
- if (typeof body !== "object" || body === null) return null;
38
- const config = body;
39
- if (!Array.isArray(config.thresholdsSeconds)) return null;
40
- try {
41
- assertValidThresholds(config);
42
- } catch {
43
- return null;
44
- }
45
- return { thresholdsSeconds: [...config.thresholdsSeconds] };
46
- }
47
- function thresholdsOrDefault(config) {
48
- return parseQueueHealthConfig(config)?.thresholdsSeconds ?? DEFAULT_QUEUE_HEALTH_CONFIG.thresholdsSeconds;
49
- }
50
-
51
- // src/storage/domains/work-items/base.ts
52
- import { createHash } from "crypto";
53
- import { FactoryStorageDomain as FactoryStorageDomain2, UniqueViolationError as UniqueViolationError2 } from "@mastra/core/storage";
54
- var AUTOMATION_ACTORS = /* @__PURE__ */ new Set([
55
- "factory",
56
- "system",
57
- "automation",
58
- "factory-rule-dispatcher",
59
- "factory-tool-result-rule"
60
- ]);
61
- function isAutomationActor(by) {
62
- if (by === void 0) return false;
63
- return AUTOMATION_ACTORS.has(by) || by.startsWith("agent:") || by.startsWith("github:");
64
- }
65
- var WorkItemRelationError = class extends Error {
66
- code = "invalid_work_item_relation";
67
- };
68
-
69
- // src/storage/domains/work-items/metrics.ts
70
- var DEFAULT_METRICS_WINDOW = 30;
71
- var MAX_METRICS_WINDOW = 366;
72
- var DAY_MS = 864e5;
73
- var DONE_STAGE = "done";
74
- var CANCELED_STAGE = "canceled";
75
- var TERMINAL_STAGES = /* @__PURE__ */ new Set([DONE_STAGE, CANCELED_STAGE]);
76
- var AGING_WIP_LIMIT = 10;
77
- var DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
78
- var ZONED_DATETIME_RE = /(?:[Zz]|[+-]\d{2}:?\d{2})$/;
79
- function parseRangeParam(value, boundary) {
80
- if (typeof value !== "string" || value.length === 0) return void 0;
81
- const dateOnly = DATE_ONLY_RE.test(value);
82
- if (!dateOnly && !ZONED_DATETIME_RE.test(value)) return void 0;
83
- const time = Date.parse(value);
84
- if (Number.isNaN(time)) return void 0;
85
- return boundary === "to" && dateOnly ? time + DAY_MS : time;
86
- }
87
- function utcDayStart(time) {
88
- return Date.parse(`${utcDay(time)}T00:00:00Z`);
89
- }
90
- function parseMetricsRange(fromParam, toParam, now) {
91
- const nowMs = now.getTime();
92
- const endOfToday = utcDayStart(nowMs) + DAY_MS;
93
- const requestedEnd = parseRangeParam(toParam, "to") ?? endOfToday;
94
- const windowEnd = Math.min(requestedEnd, endOfToday);
95
- const lastIncludedDay = utcDayStart(windowEnd - 1);
96
- const defaultStart = lastIncludedDay - (DEFAULT_METRICS_WINDOW - 1) * DAY_MS;
97
- const parsedFrom = parseRangeParam(fromParam, "from");
98
- let windowStart = parsedFrom !== void 0 && parsedFrom < windowEnd ? parsedFrom : defaultStart;
99
- const earliestStart = lastIncludedDay - (MAX_METRICS_WINDOW - 1) * DAY_MS;
100
- if (windowStart < earliestStart) windowStart = earliestStart;
101
- return { windowStart, windowEnd };
102
- }
103
- function parseTime(iso) {
104
- const time = Date.parse(iso);
105
- return Number.isNaN(time) ? 0 : time;
106
- }
107
- function percentile(samples, fraction) {
108
- if (samples.length === 0) return null;
109
- const sorted = [...samples].sort((a, b) => a - b);
110
- const rank = Math.max(1, Math.ceil(fraction * sorted.length));
111
- return sorted[rank - 1];
112
- }
113
- function utcDay(time) {
114
- return new Date(time).toISOString().slice(0, 10);
115
- }
116
- function completedAt(item) {
117
- if (!item.stages.includes(DONE_STAGE)) return void 0;
118
- for (let i = item.stageHistory.length - 1; i >= 0; i--) {
119
- const entry = item.stageHistory[i];
120
- if (entry.stage === DONE_STAGE && entry.exitedAt === void 0) return parseTime(entry.enteredAt);
121
- }
122
- return void 0;
123
- }
124
- function openEntries(item) {
125
- return item.stageHistory.filter(
126
- (entry) => entry.exitedAt === void 0 && !TERMINAL_STAGES.has(entry.stage) && item.stages.includes(entry.stage)
127
- );
128
- }
129
- function computeFactoryMetrics(items, opts) {
130
- const { windowStart, windowEnd } = opts;
131
- let earliest = Infinity;
132
- for (const item of items) earliest = Math.min(earliest, item.createdAt.getTime());
133
- const earliestItemAt = Number.isFinite(earliest) ? new Date(earliest).toISOString() : null;
134
- const throughputByDay = /* @__PURE__ */ new Map();
135
- const firstDay = utcDayStart(windowStart);
136
- for (let day = firstDay; day < windowEnd; day += DAY_MS) {
137
- throughputByDay.set(utcDay(day), 0);
138
- }
139
- const cycleSamples = [];
140
- for (const item of items) {
141
- const doneAt = completedAt(item);
142
- if (doneAt === void 0 || doneAt < windowStart || doneAt >= windowEnd) continue;
143
- const day = utcDay(doneAt);
144
- throughputByDay.set(day, (throughputByDay.get(day) ?? 0) + 1);
145
- cycleSamples.push(Math.max(0, doneAt - item.createdAt.getTime()));
146
- }
147
- const durationsByStage = /* @__PURE__ */ new Map();
148
- for (const item of items) {
149
- for (const entry of item.stageHistory) {
150
- if (entry.exitedAt === void 0 || TERMINAL_STAGES.has(entry.stage)) continue;
151
- const exited = parseTime(entry.exitedAt);
152
- if (exited < windowStart || exited >= windowEnd) continue;
153
- const duration = Math.max(0, exited - parseTime(entry.enteredAt));
154
- const samples = durationsByStage.get(entry.stage) ?? [];
155
- samples.push(duration);
156
- durationsByStage.set(entry.stage, samples);
157
- }
158
- }
159
- const wipByStage = /* @__PURE__ */ new Map();
160
- let wipTotal = 0;
161
- const aging = [];
162
- for (const item of items) {
163
- for (const stage of item.stages) {
164
- wipByStage.set(stage, (wipByStage.get(stage) ?? 0) + 1);
165
- }
166
- const inFlightStages = item.stages.filter((stage) => !TERMINAL_STAGES.has(stage));
167
- if (inFlightStages.length === 0) continue;
168
- wipTotal += 1;
169
- const open = openEntries(item);
170
- const oldest = open.reduce(
171
- (best, entry) => !best || parseTime(entry.enteredAt) < parseTime(best.enteredAt) ? entry : best,
172
- void 0
173
- );
174
- aging.push({
175
- id: item.id,
176
- title: item.title,
177
- stage: oldest?.stage ?? inFlightStages[0],
178
- enteredAt: oldest?.enteredAt ?? item.createdAt.toISOString(),
179
- url: item.externalSource?.url ?? null
180
- });
181
- }
182
- aging.sort((a, b) => parseTime(a.enteredAt) - parseTime(b.enteredAt));
183
- const sourceCounts = /* @__PURE__ */ new Map();
184
- let transitionsTotal = 0;
185
- let transitionsHuman = 0;
186
- for (const item of items) {
187
- if (item.createdAt.getTime() >= windowStart && item.createdAt.getTime() < windowEnd) {
188
- const source = item.externalSource ? `${item.externalSource.integrationId}:${item.externalSource.type}` : "manual";
189
- sourceCounts.set(source, (sourceCounts.get(source) ?? 0) + 1);
190
- }
191
- for (const entry of item.stageHistory) {
192
- const entered = parseTime(entry.enteredAt);
193
- if (entered < windowStart || entered >= windowEnd) continue;
194
- transitionsTotal += 1;
195
- if (!isAutomationActor(entry.by)) transitionsHuman += 1;
196
- }
197
- }
198
- const automationByStage = /* @__PURE__ */ new Map();
199
- for (const item of items) {
200
- const itemDone = completedAt(item) !== void 0;
201
- const itemCanceled = item.stages.includes(CANCELED_STAGE);
202
- for (let i = 0; i < item.stageHistory.length; i++) {
203
- const entry = item.stageHistory[i];
204
- if (entry.exitedAt === void 0 || TERMINAL_STAGES.has(entry.stage)) continue;
205
- const exited = parseTime(entry.exitedAt);
206
- if (exited < windowStart || exited >= windowEnd) continue;
207
- let row = automationByStage.get(entry.stage);
208
- if (!row) {
209
- row = {
210
- stage: entry.stage,
211
- exits: 0,
212
- automated: 0,
213
- outcomes: { done: 0, canceled: 0, reworked: 0, inFlight: 0 }
214
- };
215
- automationByStage.set(entry.stage, row);
216
- }
217
- row.exits += 1;
218
- const firstVisitIndex = item.stageHistory.findIndex((e) => e.stage === entry.stage);
219
- if (firstVisitIndex !== i || !isAutomationActor(entry.by) || !isAutomationActor(entry.exitedBy)) continue;
220
- row.automated += 1;
221
- const reworked = item.stageHistory.some((e, j) => j > i && e.stage === entry.stage);
222
- if (reworked) row.outcomes.reworked += 1;
223
- else if (itemDone) row.outcomes.done += 1;
224
- else if (itemCanceled) row.outcomes.canceled += 1;
225
- else row.outcomes.inFlight += 1;
226
- }
227
- }
228
- return {
229
- windowDays: throughputByDay.size,
230
- earliestItemAt,
231
- throughput: [...throughputByDay.entries()].map(([date, count]) => ({ date, count })).sort((a, b) => a.date.localeCompare(b.date)),
232
- cycleTime: {
233
- medianMs: percentile(cycleSamples, 0.5),
234
- p90Ms: percentile(cycleSamples, 0.9),
235
- samples: cycleSamples.length
236
- },
237
- stageDurations: [...durationsByStage.entries()].map(([stage, samples]) => ({
238
- stage,
239
- medianMs: percentile(samples, 0.5),
240
- samples: samples.length
241
- })),
242
- wip: [...wipByStage.entries()].map(([stage, count]) => ({ stage, count })),
243
- wipTotal,
244
- agingWip: aging.slice(0, AGING_WIP_LIMIT),
245
- sourceMix: [...sourceCounts.entries()].map(([source, count]) => ({ source, count })).sort((a, b) => b.count - a.count),
246
- transitions: { human: transitionsHuman, total: transitionsTotal },
247
- stageAutomation: [...automationByStage.values()]
248
- };
249
- }
250
-
251
- // src/routes/route.ts
252
- var Route = class {
253
- deps;
254
- constructor(deps) {
255
- this.deps = deps;
256
- }
257
- };
258
-
259
- // src/routes/work-items.ts
8
+ //#region src/routes/work-items.ts
260
9
  function loose(c) {
261
- return c;
10
+ return c;
262
11
  }
263
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
264
- var MAX_STAGES = 16;
265
- var MAX_STAGE_LENGTH = 64;
266
- var MAX_METADATA_BYTES = 16 * 1024;
12
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
+ const MAX_STAGES = 16;
14
+ const MAX_STAGE_LENGTH = 64;
15
+ const MAX_METADATA_BYTES = 16 * 1024;
267
16
  function isRecord(value) {
268
- return typeof value === "object" && value !== null && !Array.isArray(value);
17
+ return typeof value === "object" && value !== null && !Array.isArray(value);
269
18
  }
270
19
  function validStages(value) {
271
- return Array.isArray(value) && value.length > 0 && value.length <= MAX_STAGES && value.every(
272
- (stage) => typeof stage === "string" && stage.length <= MAX_STAGE_LENGTH && /^[a-z0-9][a-z0-9_-]*$/i.test(stage)
273
- ) && new Set(value).size === value.length;
20
+ return Array.isArray(value) && value.length > 0 && value.length <= MAX_STAGES && value.every((stage) => typeof stage === "string" && stage.length <= MAX_STAGE_LENGTH && /^[a-z0-9][a-z0-9_-]*$/i.test(stage)) && new Set(value).size === value.length;
274
21
  }
275
22
  function validMetadata(value) {
276
- if (value === null) return true;
277
- if (!isRecord(value)) return false;
278
- try {
279
- return JSON.stringify(value).length <= MAX_METADATA_BYTES;
280
- } catch {
281
- return false;
282
- }
23
+ if (value === null) return true;
24
+ if (!isRecord(value)) return false;
25
+ try {
26
+ return JSON.stringify(value).length <= MAX_METADATA_BYTES;
27
+ } catch {
28
+ return false;
29
+ }
283
30
  }
284
31
  function parseExternalSource(value) {
285
- if (value === void 0 || value === null) return value;
286
- if (!isRecord(value)) return void 0;
287
- const { integrationId, type, externalId, url } = value;
288
- if (typeof integrationId !== "string" || integrationId.length === 0 || integrationId.length > 128) return void 0;
289
- if (typeof type !== "string" || type.length === 0 || type.length > 128) return void 0;
290
- if (typeof externalId !== "string" || externalId.length === 0 || externalId.length > 512) return void 0;
291
- if (url !== void 0 && (typeof url !== "string" || url.length > 2048)) return void 0;
292
- return { integrationId, type, externalId, ...url !== void 0 ? { url } : {} };
32
+ if (value === void 0 || value === null) return value;
33
+ if (!isRecord(value)) return void 0;
34
+ const { integrationId, type, externalId, url } = value;
35
+ if (typeof integrationId !== "string" || integrationId.length === 0 || integrationId.length > 128) return void 0;
36
+ if (typeof type !== "string" || type.length === 0 || type.length > 128) return void 0;
37
+ if (typeof externalId !== "string" || externalId.length === 0 || externalId.length > 512) return void 0;
38
+ if (url !== void 0 && (typeof url !== "string" || url.length > 2048)) return void 0;
39
+ return {
40
+ integrationId,
41
+ type,
42
+ externalId,
43
+ ...url !== void 0 ? { url } : {}
44
+ };
293
45
  }
294
46
  function parseParentWorkItemId(value) {
295
- if (value === null) return null;
296
- if (typeof value !== "string" || !UUID_RE.test(value)) return void 0;
297
- return value;
47
+ if (value === null) return null;
48
+ if (typeof value !== "string" || !UUID_RE.test(value)) return void 0;
49
+ return value;
298
50
  }
299
51
  function parseSessions(value) {
300
- if (!isRecord(value)) return void 0;
301
- const out = {};
302
- for (const [role, session] of Object.entries(value)) {
303
- if (!role || role.length > 64 || !isRecord(session)) return void 0;
304
- const { sessionId, branch, threadId } = session;
305
- if (typeof sessionId !== "string" || sessionId.length === 0 || sessionId.length > 512) return void 0;
306
- if (typeof branch !== "string" || branch.length === 0 || branch.length > 512) return void 0;
307
- if (typeof threadId !== "string" || threadId.length === 0 || threadId.length > 512) return void 0;
308
- out[role] = { sessionId, branch, threadId };
309
- }
310
- return out;
311
- }
52
+ if (!isRecord(value)) return void 0;
53
+ const out = {};
54
+ for (const [role, session] of Object.entries(value)) {
55
+ if (!role || role.length > 64 || !isRecord(session)) return void 0;
56
+ const { sessionId, branch, threadId } = session;
57
+ if (typeof sessionId !== "string" || sessionId.length === 0 || sessionId.length > 512) return void 0;
58
+ if (typeof branch !== "string" || branch.length === 0 || branch.length > 512) return void 0;
59
+ if (typeof threadId !== "string" || threadId.length === 0 || threadId.length > 512) return void 0;
60
+ out[role] = {
61
+ sessionId,
62
+ branch,
63
+ threadId
64
+ };
65
+ }
66
+ return out;
67
+ }
68
+ /** Validate an untrusted create body. Unknown keys are dropped. */
312
69
  function parseCreateWorkItem(body) {
313
- if (!isRecord(body)) return null;
314
- const { externalSource, title, stages, sessions, metadata } = body;
315
- if (typeof title !== "string" || title.trim().length === 0 || title.length > 500) return null;
316
- const hasParentWorkItemId = "parentWorkItemId" in body;
317
- const parentWorkItemId = hasParentWorkItemId ? parseParentWorkItemId(body.parentWorkItemId) : void 0;
318
- if (hasParentWorkItemId && parentWorkItemId === void 0) return null;
319
- const parsedSource = parseExternalSource(externalSource);
320
- if (externalSource !== void 0 && parsedSource === void 0) return null;
321
- if (stages !== void 0 && !validStages(stages)) return null;
322
- const parsedSessions = sessions === void 0 ? void 0 : parseSessions(sessions);
323
- if (sessions !== void 0 && parsedSessions === void 0) return null;
324
- if (metadata !== void 0 && !validMetadata(metadata)) return null;
325
- return {
326
- title: title.trim(),
327
- ...parsedSource !== void 0 ? { externalSource: parsedSource } : {},
328
- ...hasParentWorkItemId ? { parentWorkItemId: parentWorkItemId ?? null } : {},
329
- ...stages !== void 0 ? { stages } : {},
330
- ...parsedSessions !== void 0 ? { sessions: parsedSessions } : {},
331
- ...metadata !== void 0 ? { metadata } : {}
332
- };
333
- }
70
+ if (!isRecord(body)) return null;
71
+ const { externalSource, title, stages, sessions, metadata } = body;
72
+ if (typeof title !== "string" || title.trim().length === 0 || title.length > 500) return null;
73
+ const hasParentWorkItemId = "parentWorkItemId" in body;
74
+ const parentWorkItemId = hasParentWorkItemId ? parseParentWorkItemId(body.parentWorkItemId) : void 0;
75
+ if (hasParentWorkItemId && parentWorkItemId === void 0) return null;
76
+ const parsedSource = parseExternalSource(externalSource);
77
+ if (externalSource !== void 0 && parsedSource === void 0) return null;
78
+ if (stages !== void 0 && !validStages(stages)) return null;
79
+ const parsedSessions = sessions === void 0 ? void 0 : parseSessions(sessions);
80
+ if (sessions !== void 0 && parsedSessions === void 0) return null;
81
+ if (metadata !== void 0 && !validMetadata(metadata)) return null;
82
+ return {
83
+ title: title.trim(),
84
+ ...parsedSource !== void 0 ? { externalSource: parsedSource } : {},
85
+ ...hasParentWorkItemId ? { parentWorkItemId: parentWorkItemId ?? null } : {},
86
+ ...stages !== void 0 ? { stages } : {},
87
+ ...parsedSessions !== void 0 ? { sessions: parsedSessions } : {},
88
+ ...metadata !== void 0 ? { metadata } : {}
89
+ };
90
+ }
91
+ /** Validate an untrusted patch body. Unknown keys are dropped. */
334
92
  function parseUpdateWorkItem(body) {
335
- if (!isRecord(body)) return null;
336
- const { title, stages, sessions, metadata } = body;
337
- const hasParentWorkItemId = "parentWorkItemId" in body;
338
- if (title === void 0 && stages === void 0 && sessions === void 0 && metadata === void 0 && !hasParentWorkItemId)
339
- return null;
340
- const parentWorkItemId = hasParentWorkItemId ? parseParentWorkItemId(body.parentWorkItemId) : void 0;
341
- if (hasParentWorkItemId && parentWorkItemId === void 0) return null;
342
- if (title !== void 0 && (typeof title !== "string" || title.trim().length === 0 || title.length > 500))
343
- return null;
344
- if (stages !== void 0 && !validStages(stages)) return null;
345
- const parsedSessions = sessions === void 0 ? void 0 : parseSessions(sessions);
346
- if (sessions !== void 0 && parsedSessions === void 0) return null;
347
- if (metadata !== void 0 && !validMetadata(metadata)) return null;
348
- return {
349
- ...hasParentWorkItemId ? { parentWorkItemId: parentWorkItemId ?? null } : {},
350
- ...title !== void 0 ? { title: title.trim() } : {},
351
- ...stages !== void 0 ? { stages } : {},
352
- ...parsedSessions !== void 0 ? { sessions: parsedSessions } : {},
353
- ...metadata !== void 0 ? { metadata } : {}
354
- };
93
+ if (!isRecord(body)) return null;
94
+ const { title, stages, sessions, metadata } = body;
95
+ const hasParentWorkItemId = "parentWorkItemId" in body;
96
+ if (title === void 0 && stages === void 0 && sessions === void 0 && metadata === void 0 && !hasParentWorkItemId) return null;
97
+ const parentWorkItemId = hasParentWorkItemId ? parseParentWorkItemId(body.parentWorkItemId) : void 0;
98
+ if (hasParentWorkItemId && parentWorkItemId === void 0) return null;
99
+ if (title !== void 0 && (typeof title !== "string" || title.trim().length === 0 || title.length > 500)) return null;
100
+ if (stages !== void 0 && !validStages(stages)) return null;
101
+ const parsedSessions = sessions === void 0 ? void 0 : parseSessions(sessions);
102
+ if (sessions !== void 0 && parsedSessions === void 0) return null;
103
+ if (metadata !== void 0 && !validMetadata(metadata)) return null;
104
+ return {
105
+ ...hasParentWorkItemId ? { parentWorkItemId: parentWorkItemId ?? null } : {},
106
+ ...title !== void 0 ? { title: title.trim() } : {},
107
+ ...stages !== void 0 ? { stages } : {},
108
+ ...parsedSessions !== void 0 ? { sessions: parsedSessions } : {},
109
+ ...metadata !== void 0 ? { metadata } : {}
110
+ };
355
111
  }
356
112
  async function readJson(c) {
357
- try {
358
- return await c.req.json();
359
- } catch {
360
- return void 0;
361
- }
113
+ try {
114
+ return await c.req.json();
115
+ } catch {
116
+ return;
117
+ }
362
118
  }
119
+ /** Fields a PATCH touched, for the bounded `updated` event summary. */
363
120
  function patchedFields(patch) {
364
- return Object.keys(patch).filter((key) => patch[key] !== void 0);
121
+ return Object.keys(patch).filter((key) => patch[key] !== void 0);
365
122
  }
366
123
  function boundedText(value, max) {
367
- if (typeof value !== "string") return void 0;
368
- const normalized = value.trim();
369
- return normalized.length > 0 && normalized.length <= max ? normalized : void 0;
124
+ if (typeof value !== "string") return void 0;
125
+ const normalized = value.trim();
126
+ return normalized.length > 0 && normalized.length <= max ? normalized : void 0;
370
127
  }
371
128
  function parseTransitionBody(body) {
372
- if (!isRecord(body)) return null;
373
- const board = FACTORY_RULE_BOARDS.includes(body.board) ? body.board : void 0;
374
- const stage = FACTORY_RULE_STAGES.includes(body.stage) ? body.stage : void 0;
375
- const requestId = boundedText(body.requestId, 256);
376
- const cause = boundedText(body.cause, 256);
377
- if (!board || !stage || !requestId || !UUID_RE.test(requestId) || !cause || !Number.isInteger(body.expectedRevision) || Number(body.expectedRevision) < 1) {
378
- return null;
379
- }
380
- return {
381
- board,
382
- stage,
383
- expectedRevision: Number(body.expectedRevision),
384
- ingress: { type: "human", identity: requestId },
385
- cause
386
- };
129
+ if (!isRecord(body)) return null;
130
+ const board = FACTORY_RULE_BOARDS.includes(body.board) ? body.board : void 0;
131
+ const stage = FACTORY_RULE_STAGES.includes(body.stage) ? body.stage : void 0;
132
+ const requestId = boundedText(body.requestId, 256);
133
+ const cause = boundedText(body.cause, 256);
134
+ if (!board || !stage || !requestId || !UUID_RE.test(requestId) || !cause || !Number.isInteger(body.expectedRevision) || Number(body.expectedRevision) < 1) return null;
135
+ return {
136
+ board,
137
+ stage,
138
+ expectedRevision: Number(body.expectedRevision),
139
+ ingress: {
140
+ type: "human",
141
+ identity: requestId
142
+ },
143
+ cause
144
+ };
387
145
  }
388
146
  function parseInvocation(value) {
389
- if (value === void 0) return void 0;
390
- if (!isRecord(value)) return null;
391
- if (value.type === "prompt") {
392
- const prompt = boundedText(value.prompt, 16384);
393
- return prompt ? { type: "prompt", prompt } : null;
394
- }
395
- if (value.type === "skill") {
396
- const skillName = boundedText(value.skillName, 64);
397
- const args = typeof value.arguments === "string" && value.arguments.length <= 16384 ? value.arguments : void 0;
398
- return skillName && args !== void 0 ? { type: "skill", skillName, arguments: args } : null;
399
- }
400
- return null;
147
+ if (value === void 0) return void 0;
148
+ if (!isRecord(value)) return null;
149
+ if (value.type === "prompt") {
150
+ const prompt = boundedText(value.prompt, 16384);
151
+ return prompt ? {
152
+ type: "prompt",
153
+ prompt
154
+ } : null;
155
+ }
156
+ if (value.type === "skill") {
157
+ const skillName = boundedText(value.skillName, 64);
158
+ const args = typeof value.arguments === "string" && value.arguments.length <= 16384 ? value.arguments : void 0;
159
+ return skillName && args !== void 0 ? {
160
+ type: "skill",
161
+ skillName,
162
+ arguments: args
163
+ } : null;
164
+ }
165
+ return null;
401
166
  }
402
167
  function parseStartBody(body, tenant, factoryProjectId) {
403
- if (!isRecord(body) || !isRecord(body.workItem)) return null;
404
- const input = parseCreateWorkItem(body.workItem.input);
405
- const sessionId = boundedText(body.sessionId, 256);
406
- const threadTitle = boundedText(body.threadTitle, 512);
407
- const kickoffKey = boundedText(body.kickoffKey, 256);
408
- const invocation = parseInvocation(body.invocation);
409
- const destinationStage = FACTORY_RULE_STAGES.includes(body.destinationStage) ? body.destinationStage : void 0;
410
- const role = boundedText(body.workItem.role, 32);
411
- const id = body.workItem.id === void 0 ? void 0 : boundedText(body.workItem.id, 64);
412
- if (body.workItem.id !== void 0 && (!id || !UUID_RE.test(id))) return null;
413
- if (!input || !sessionId || !UUID_RE.test(sessionId) || !threadTitle || !kickoffKey || !UUID_RE.test(kickoffKey) || invocation === null || !destinationStage || !role) {
414
- return null;
415
- }
416
- const threadTags = isRecord(body.threadTags) ? Object.fromEntries(
417
- Object.entries(body.threadTags).filter(
418
- (entry) => boundedText(entry[0], 64) !== void 0 && boundedText(entry[1], 256) !== void 0
419
- ).map(([key, value]) => [key, value.trim()])
420
- ) : void 0;
421
- return {
422
- ...tenant,
423
- factoryProjectId,
424
- sessionId,
425
- threadTitle,
426
- threadTags,
427
- kickoffKey,
428
- invocation,
429
- destinationStage,
430
- workItem: { id, role, input }
431
- };
432
- }
433
- var DECISION_STATUSES = /* @__PURE__ */ new Set(["pending", "leased", "retry", "succeeded", "failed"]);
434
- var DEFAULT_DECISION_PAGE_SIZE = 25;
435
- var MAX_DECISION_PAGE_SIZE = 50;
168
+ if (!isRecord(body) || !isRecord(body.workItem)) return null;
169
+ const input = parseCreateWorkItem(body.workItem.input);
170
+ const sessionId = boundedText(body.sessionId, 256);
171
+ const threadTitle = boundedText(body.threadTitle, 512);
172
+ const kickoffKey = boundedText(body.kickoffKey, 256);
173
+ const invocation = parseInvocation(body.invocation);
174
+ const destinationStage = FACTORY_RULE_STAGES.includes(body.destinationStage) ? body.destinationStage : void 0;
175
+ const role = boundedText(body.workItem.role, 32);
176
+ const id = body.workItem.id === void 0 ? void 0 : boundedText(body.workItem.id, 64);
177
+ if (body.workItem.id !== void 0 && (!id || !UUID_RE.test(id))) return null;
178
+ if (!input || !sessionId || !UUID_RE.test(sessionId) || !threadTitle || !kickoffKey || !UUID_RE.test(kickoffKey) || invocation === null || !destinationStage || !role) return null;
179
+ const threadTags = isRecord(body.threadTags) ? Object.fromEntries(Object.entries(body.threadTags).filter((entry) => boundedText(entry[0], 64) !== void 0 && boundedText(entry[1], 256) !== void 0).map(([key, value]) => [key, value.trim()])) : void 0;
180
+ return {
181
+ ...tenant,
182
+ factoryProjectId,
183
+ sessionId,
184
+ threadTitle,
185
+ threadTags,
186
+ kickoffKey,
187
+ invocation,
188
+ destinationStage,
189
+ workItem: {
190
+ id,
191
+ role,
192
+ input
193
+ }
194
+ };
195
+ }
196
+ const DECISION_STATUSES = /* @__PURE__ */ new Set([
197
+ "pending",
198
+ "leased",
199
+ "retry",
200
+ "succeeded",
201
+ "failed"
202
+ ]);
203
+ const DEFAULT_DECISION_PAGE_SIZE = 25;
204
+ const MAX_DECISION_PAGE_SIZE = 50;
436
205
  function parseDecisionStatuses(raw) {
437
- if (!raw) return void 0;
438
- const statuses = [...new Set(raw.split(",").map((status) => status.trim()))].filter(
439
- (status) => DECISION_STATUSES.has(status)
440
- );
441
- return statuses.length > 0 ? statuses : void 0;
206
+ if (!raw) return void 0;
207
+ const statuses = [...new Set(raw.split(",").map((status) => status.trim()))].filter((status) => DECISION_STATUSES.has(status));
208
+ return statuses.length > 0 ? statuses : void 0;
442
209
  }
443
210
  function parseDecisionLimit(raw) {
444
- const parsed = raw ? Number.parseInt(raw, 10) : DEFAULT_DECISION_PAGE_SIZE;
445
- if (!Number.isFinite(parsed)) return DEFAULT_DECISION_PAGE_SIZE;
446
- return Math.max(1, Math.min(MAX_DECISION_PAGE_SIZE, parsed));
211
+ const parsed = raw ? Number.parseInt(raw, 10) : DEFAULT_DECISION_PAGE_SIZE;
212
+ if (!Number.isFinite(parsed)) return DEFAULT_DECISION_PAGE_SIZE;
213
+ return Math.max(1, Math.min(MAX_DECISION_PAGE_SIZE, parsed));
447
214
  }
448
215
  function encodeDecisionCursor(decision) {
449
- return Buffer.from(JSON.stringify([decision.createdAt.toISOString(), decision.id]), "utf8").toString("base64url");
216
+ return Buffer.from(JSON.stringify([decision.createdAt.toISOString(), decision.id]), "utf8").toString("base64url");
450
217
  }
451
218
  function parseDecisionCursor(raw) {
452
- if (!raw) return void 0;
453
- try {
454
- const decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
455
- if (!Array.isArray(decoded) || decoded.length !== 2 || typeof decoded[0] !== "string" || typeof decoded[1] !== "string") {
456
- return void 0;
457
- }
458
- const createdAt = new Date(decoded[0]);
459
- if (Number.isNaN(createdAt.getTime()) || !UUID_RE.test(decoded[1])) return void 0;
460
- return { createdAt, id: decoded[1] };
461
- } catch {
462
- return void 0;
463
- }
219
+ if (!raw) return void 0;
220
+ try {
221
+ const decoded = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
222
+ if (!Array.isArray(decoded) || decoded.length !== 2 || typeof decoded[0] !== "string" || typeof decoded[1] !== "string") return;
223
+ const createdAt = new Date(decoded[0]);
224
+ if (Number.isNaN(createdAt.getTime()) || !UUID_RE.test(decoded[1])) return void 0;
225
+ return {
226
+ createdAt,
227
+ id: decoded[1]
228
+ };
229
+ } catch {
230
+ return;
231
+ }
464
232
  }
465
233
  function decisionSummary(decision) {
466
- const type = typeof decision.decision.type === "string" ? decision.decision.type.slice(0, 64) : "unknown";
467
- return {
468
- id: decision.id,
469
- evaluationId: decision.evaluationId,
470
- workItemId: decision.workItemId,
471
- type,
472
- status: decision.status,
473
- attempts: decision.attempts,
474
- lastError: decision.lastError?.slice(0, 512) ?? null,
475
- createdAt: decision.createdAt.toISOString(),
476
- updatedAt: decision.updatedAt.toISOString(),
477
- completedAt: decision.completedAt?.toISOString() ?? null
478
- };
234
+ const type = typeof decision.decision.type === "string" ? decision.decision.type.slice(0, 64) : "unknown";
235
+ return {
236
+ id: decision.id,
237
+ evaluationId: decision.evaluationId,
238
+ workItemId: decision.workItemId,
239
+ type,
240
+ status: decision.status,
241
+ attempts: decision.attempts,
242
+ lastError: decision.lastError?.slice(0, 512) ?? null,
243
+ createdAt: decision.createdAt.toISOString(),
244
+ updatedAt: decision.updatedAt.toISOString(),
245
+ completedAt: decision.completedAt?.toISOString() ?? null
246
+ };
479
247
  }
480
248
  var WorkItemRoutes = class extends Route {
481
- /** Resolve the `(orgId, userId)` tenant or a ready-to-return error response. */
482
- async #resolveTenant(c) {
483
- await this.deps.auth.ensureUser(c);
484
- const tenant = this.deps.auth.tenant(c);
485
- if (!tenant) return { response: c.json({ error: "unauthorized" }, 401) };
486
- if (!tenant.orgId) {
487
- return {
488
- response: c.json(
489
- { error: "organization_required", message: "The Factory board requires an organization." },
490
- 403
491
- )
492
- };
493
- }
494
- return { orgId: tenant.orgId, userId: tenant.userId };
495
- }
496
- /**
497
- * Resolve the tenant AND the org-owned project from the `:id` param. Work
498
- * items hang off a project, so listing/creating requires the project to
499
- * exist in the caller's org.
500
- */
501
- async #resolveProject(c) {
502
- const tenant = await this.#resolveTenant(c);
503
- if ("response" in tenant) return tenant;
504
- const projectId = c.req.param("id");
505
- if (!projectId || !UUID_RE.test(projectId)) {
506
- return { response: c.json({ error: "Project not found" }, 404) };
507
- }
508
- const { projects } = this.deps;
509
- await projects.ensureReady();
510
- const project = await projects.get({ orgId: tenant.orgId, id: projectId });
511
- if (!project) {
512
- return { response: c.json({ error: "Project not found" }, 404) };
513
- }
514
- return { ...tenant, factoryProjectId: projectId, defaultModelId: project.defaultModelId };
515
- }
516
- /**
517
- * Emit the audit events a successful work-item PATCH implies: always
518
- * `updated`, plus `stage_moved` when the stages actually changed and one
519
- * `run.started` per session role the patch introduced.
520
- */
521
- async #auditWorkItemPatch({
522
- context,
523
- item,
524
- previous,
525
- patch
526
- }) {
527
- const { audit } = this.deps;
528
- const target = { type: "work_item", id: item.id, name: item.title };
529
- await audit.emit({
530
- context,
531
- input: {
532
- action: "factory.work_item.updated",
533
- factoryProjectId: item.factoryProjectId,
534
- targets: [target],
535
- metadata: { fields: patchedFields(patch) }
536
- }
537
- });
538
- const stagesChanged = patch.stages !== void 0 && (previous.stages.length !== item.stages.length || previous.stages.some((s, i) => s !== item.stages[i]));
539
- if (stagesChanged) {
540
- await audit.emit({
541
- context,
542
- input: {
543
- action: "factory.work_item.stage_moved",
544
- factoryProjectId: item.factoryProjectId,
545
- targets: [target],
546
- metadata: { from: previous.stages, to: item.stages }
547
- }
548
- });
549
- }
550
- const newRoles = Object.keys(item.sessions).filter((role) => !previous.sessionRoles.includes(role));
551
- for (const role of newRoles) {
552
- const session = item.sessions[role];
553
- await audit.emit({
554
- context,
555
- input: {
556
- action: "factory.run.started",
557
- factoryProjectId: item.factoryProjectId,
558
- targets: [target],
559
- metadata: {
560
- role,
561
- branch: session?.branch,
562
- threadId: session?.threadId,
563
- sessionId: session?.sessionId
564
- }
565
- }
566
- });
567
- }
568
- }
569
- /** Build the Factory work-item routes as Mastra `apiRoutes`. */
570
- routes() {
571
- const { audit, workItems, queueHealth, transitionService, startCoordinator } = this.deps;
572
- return [
573
- // ── List the org's work items for a project ─────────────────────────────
574
- registerApiRoute("/web/factory/projects/:id/work-items", {
575
- method: "GET",
576
- requiresAuth: false,
577
- handler: async (c) => {
578
- const resolved = await this.#resolveProject(loose(c));
579
- if ("response" in resolved) return resolved.response;
580
- await workItems.ensureReady();
581
- const items = await workItems.list({
582
- orgId: resolved.orgId,
583
- factoryProjectId: resolved.factoryProjectId
584
- });
585
- return c.json({ workItems: items });
586
- }
587
- }),
588
- // ── Flow metrics aggregated over the project's work items ───────────────
589
- registerApiRoute("/web/factory/projects/:id/metrics", {
590
- method: "GET",
591
- requiresAuth: false,
592
- handler: async (c) => {
593
- const resolved = await this.#resolveProject(loose(c));
594
- if ("response" in resolved) return resolved.response;
595
- const { windowStart, windowEnd } = parseMetricsRange(
596
- loose(c).req.query("from"),
597
- loose(c).req.query("to"),
598
- /* @__PURE__ */ new Date()
599
- );
600
- await workItems.ensureReady();
601
- const items = await workItems.list({
602
- orgId: resolved.orgId,
603
- factoryProjectId: resolved.factoryProjectId
604
- });
605
- return c.json({ metrics: computeFactoryMetrics(items, { windowStart, windowEnd }) });
606
- }
607
- }),
608
- // ── Per-project queue-health age-threshold config (seconds) ─────────────
609
- registerApiRoute("/web/factory/projects/:id/health/thresholds", {
610
- method: "GET",
611
- requiresAuth: false,
612
- handler: async (c) => {
613
- const resolved = await this.#resolveProject(loose(c));
614
- if ("response" in resolved) return resolved.response;
615
- await queueHealth.ensureReady();
616
- const stored = await queueHealth.getConfig(resolved.orgId, resolved.factoryProjectId);
617
- return c.json({ thresholds: thresholdsOrDefault(stored) });
618
- }
619
- }),
620
- // ── Bounded durable rule-decision status ────────────────────────────────
621
- registerApiRoute("/web/factory/projects/:id/decisions", {
622
- method: "GET",
623
- requiresAuth: false,
624
- handler: async (c) => {
625
- const context = loose(c);
626
- const resolved = await this.#resolveProject(context);
627
- if ("response" in resolved) return resolved.response;
628
- const cursorRaw = context.req.query("before");
629
- const before = parseDecisionCursor(cursorRaw);
630
- if (cursorRaw && !before) return c.json({ error: "invalid_cursor" }, 400);
631
- await workItems.ensureReady();
632
- const page = await workItems.listDeferredDecisionPage({
633
- orgId: resolved.orgId,
634
- factoryProjectId: resolved.factoryProjectId,
635
- statuses: parseDecisionStatuses(context.req.query("statuses")),
636
- before,
637
- limit: parseDecisionLimit(context.req.query("limit"))
638
- });
639
- const last = page.decisions.at(-1);
640
- return c.json({
641
- decisions: page.decisions.map(decisionSummary),
642
- ...page.hasMore && last ? { nextCursor: encodeDecisionCursor(last) } : {}
643
- });
644
- }
645
- }),
646
- registerApiRoute("/web/factory/projects/:id/decisions/:decisionId/retry", {
647
- method: "POST",
648
- requiresAuth: false,
649
- handler: async (c) => {
650
- const context = loose(c);
651
- const resolved = await this.#resolveProject(context);
652
- if ("response" in resolved) return resolved.response;
653
- const decisionId = context.req.param("decisionId");
654
- if (!decisionId || !UUID_RE.test(decisionId)) return c.json({ error: "invalid_decision_id" }, 422);
655
- await workItems.ensureReady();
656
- const decision = await workItems.retryDeferredDecision(
657
- resolved.orgId,
658
- resolved.factoryProjectId,
659
- decisionId,
660
- /* @__PURE__ */ new Date()
661
- );
662
- if (!decision) return c.json({ error: "decision_not_retryable" }, 409);
663
- return c.json({ decision: decisionSummary(decision) });
664
- }
665
- }),
666
- // ── Create (upsert on sourceKey) a work item ─────────────────────────────
667
- registerApiRoute("/web/factory/projects/:id/work-items", {
668
- method: "POST",
669
- requiresAuth: false,
670
- handler: async (c) => {
671
- const resolved = await this.#resolveProject(loose(c));
672
- if ("response" in resolved) return resolved.response;
673
- const body = await readJson(loose(c));
674
- if (body === void 0) return c.json({ error: "Invalid JSON body" }, 400);
675
- const input = parseCreateWorkItem(body);
676
- if (!input) return c.json({ error: "invalid_work_item" }, 400);
677
- if ((input.stages ?? ["intake"]).length !== 1 || (input.stages ?? ["intake"])[0] !== "intake") {
678
- return c.json(
679
- { error: "governed_transition_required", message: "New work items must enter through Factory intake." },
680
- 409
681
- );
682
- }
683
- await workItems.ensureReady();
684
- try {
685
- const result = await workItems.upsert({
686
- orgId: resolved.orgId,
687
- userId: resolved.userId,
688
- factoryProjectId: resolved.factoryProjectId,
689
- input,
690
- reuseMode: "non-stage"
691
- });
692
- let item = result.item;
693
- if (result.created) {
694
- if (!transitionService) {
695
- await workItems.delete({ orgId: resolved.orgId, id: item.id });
696
- return c.json({ error: "factory_transitions_unavailable" }, 503);
697
- }
698
- const entered = await transitionService.transition({
699
- orgId: resolved.orgId,
700
- factoryProjectId: resolved.factoryProjectId,
701
- workItemId: item.id,
702
- board: item.externalSource?.type === "pull-request" ? "review" : "work",
703
- stage: "intake",
704
- expectedRevision: item.revision,
705
- actor: { type: "human", id: resolved.userId },
706
- ingress: { type: "human", identity: `work-item:${item.id}:initial-entry` },
707
- cause: "work_item_created",
708
- initialEntry: true
709
- });
710
- if (entered.status === "rejected") {
711
- await workItems.delete({ orgId: resolved.orgId, id: item.id });
712
- return c.json({ status: "rejected", code: entered.code, reason: entered.reason }, 422);
713
- }
714
- item = await workItems.getForProject(resolved.orgId, resolved.factoryProjectId, item.id) ?? item;
715
- await audit.emit({
716
- context: loose(c),
717
- input: {
718
- action: "factory.work_item.created",
719
- factoryProjectId: resolved.factoryProjectId,
720
- targets: [{ type: "work_item", id: item.id, name: item.title }],
721
- metadata: { externalSource: item.externalSource, stages: item.stages }
722
- }
723
- });
724
- } else {
725
- const { stages: _stages, sessions: _sessions, ...boundedPatch } = input;
726
- await this.#auditWorkItemPatch({
727
- context: loose(c),
728
- item,
729
- previous: result.previous,
730
- patch: boundedPatch
731
- });
732
- }
733
- return c.json({ workItem: item });
734
- } catch (error) {
735
- if (error instanceof WorkItemRelationError) {
736
- return c.json({ error: error.code, message: error.message }, 400);
737
- }
738
- throw error;
739
- }
740
- }
741
- }),
742
- // ── Authoritative stage transition ──────────────────────────────────────
743
- registerApiRoute("/web/factory/projects/:id/work-items/:workItemId/transition", {
744
- method: "POST",
745
- requiresAuth: false,
746
- handler: async (c) => {
747
- const resolved = await this.#resolveProject(loose(c));
748
- if ("response" in resolved) return resolved.response;
749
- const workItemId = loose(c).req.param("workItemId");
750
- if (!workItemId || !UUID_RE.test(workItemId)) return c.json({ error: "Work item not found" }, 404);
751
- const parsed = parseTransitionBody(await readJson(loose(c)));
752
- if (!parsed) return c.json({ error: "invalid_transition_request" }, 400);
753
- if (!transitionService) {
754
- return c.json({ error: "factory_transition_unavailable" }, 503);
755
- }
756
- await workItems.ensureReady();
757
- const result = await transitionService.transition({
758
- ...parsed,
759
- orgId: resolved.orgId,
760
- factoryProjectId: resolved.factoryProjectId,
761
- workItemId,
762
- actor: { type: "human", id: resolved.userId },
763
- ingress: {
764
- ...parsed.ingress,
765
- identity: `human:${resolved.userId}:${parsed.ingress.identity}`
766
- }
767
- });
768
- await audit.emit({
769
- context: loose(c),
770
- input: {
771
- action: result.status === "accepted" ? "factory.work_item.stage_moved" : "factory.work_item.transition_rejected",
772
- factoryProjectId: resolved.factoryProjectId,
773
- targets: [{ type: "work_item", id: workItemId }],
774
- metadata: {
775
- transitionId: result.transitionId,
776
- ingressType: parsed.ingress.type,
777
- ruleSetVersion: transitionService.ruleSetVersion,
778
- ...result.status === "accepted" ? { to: result.stage, revision: result.revision } : { code: result.code, reason: result.reason }
779
- }
780
- }
781
- });
782
- if (result.status === "accepted") return c.json({ result });
783
- return c.json({ result }, result.code === "stale" ? 409 : 422);
784
- }
785
- }),
786
- // ── Bind a Factory run before dispatching its kickoff ────────────────────
787
- registerApiRoute("/web/factory/projects/:id/runs/start", {
788
- method: "POST",
789
- requiresAuth: false,
790
- handler: async (c) => {
791
- const resolved = await this.#resolveProject(loose(c));
792
- if ("response" in resolved) return resolved.response;
793
- if (!startCoordinator) {
794
- return c.json({ error: "factory_start_unavailable" }, 503);
795
- }
796
- const input = parseStartBody(await readJson(loose(c)), resolved, resolved.factoryProjectId);
797
- if (!input) return c.json({ error: "invalid_factory_start" }, 400);
798
- input.requestContext = loose(c).get("requestContext");
799
- input.defaultModelId = resolved.defaultModelId ?? void 0;
800
- if (!input.workItem.id && ((input.workItem.input.stages ?? ["intake"]).length !== 1 || (input.workItem.input.stages ?? ["intake"])[0] !== "intake")) {
801
- return c.json(
802
- { error: "governed_transition_required", message: "Create the work item in Intake before starting it." },
803
- 409
804
- );
805
- }
806
- await workItems.ensureReady();
807
- let prepared;
808
- try {
809
- prepared = await startCoordinator.prepare(input);
810
- } catch (error) {
811
- if (error instanceof FactoryStartTransitionError) {
812
- return c.json({ result: error.result }, error.result.code === "stale" ? 409 : 422);
813
- }
814
- throw error;
815
- }
816
- await audit.emit({
817
- context: loose(c),
818
- input: {
819
- action: "factory.run.started",
820
- factoryProjectId: resolved.factoryProjectId,
821
- targets: [{ type: "work_item", id: prepared.workItemId }],
822
- metadata: {
823
- role: input.workItem.role,
824
- branch: prepared.branch,
825
- threadId: prepared.threadId,
826
- sessionId: prepared.sessionId,
827
- bindingId: prepared.bindingId
828
- }
829
- }
830
- });
831
- return c.json({ prepared }, 202);
832
- }
833
- }),
834
- // ── Patch non-stage metadata / sessions / title ──────────────────────────
835
- registerApiRoute("/web/factory/work-items/:id", {
836
- method: "PATCH",
837
- requiresAuth: false,
838
- handler: async (c) => {
839
- const tenant = await this.#resolveTenant(loose(c));
840
- if ("response" in tenant) return tenant.response;
841
- const id = loose(c).req.param("id");
842
- if (!id || !UUID_RE.test(id)) return c.json({ error: "Work item not found" }, 404);
843
- const body = await readJson(loose(c));
844
- if (body === void 0) return c.json({ error: "Invalid JSON body" }, 400);
845
- const patch = parseUpdateWorkItem(body);
846
- if (!patch) return c.json({ error: "invalid_work_item_patch" }, 400);
847
- if (patch.stages !== void 0) {
848
- return c.json(
849
- { error: "governed_transition_required", message: "Use the Factory transition endpoint to move stages." },
850
- 409
851
- );
852
- }
853
- await workItems.ensureReady();
854
- try {
855
- const updated = await workItems.update({ orgId: tenant.orgId, id, userId: tenant.userId, patch });
856
- if (!updated) return c.json({ error: "Work item not found" }, 404);
857
- await this.#auditWorkItemPatch({
858
- context: loose(c),
859
- item: updated.item,
860
- previous: updated.previous,
861
- patch
862
- });
863
- return c.json({ workItem: updated.item });
864
- } catch (error) {
865
- if (error instanceof WorkItemRelationError) {
866
- return c.json({ error: error.code, message: error.message }, 400);
867
- }
868
- throw error;
869
- }
870
- }
871
- }),
872
- // ── Remove a work item ───────────────────────────────────────────────────
873
- registerApiRoute("/web/factory/work-items/:id", {
874
- method: "DELETE",
875
- requiresAuth: false,
876
- handler: async (c) => {
877
- const tenant = await this.#resolveTenant(loose(c));
878
- if ("response" in tenant) return tenant.response;
879
- const id = loose(c).req.param("id");
880
- if (!id || !UUID_RE.test(id)) return c.json({ error: "Work item not found" }, 404);
881
- await workItems.ensureReady();
882
- const deleted = await workItems.delete({ orgId: tenant.orgId, id });
883
- if (!deleted) return c.json({ error: "Work item not found" }, 404);
884
- await audit.emit({
885
- context: loose(c),
886
- input: {
887
- action: "factory.work_item.deleted",
888
- factoryProjectId: deleted.factoryProjectId,
889
- targets: [{ type: "work_item", id: deleted.id, name: deleted.title }]
890
- }
891
- });
892
- return c.json({ ok: true });
893
- }
894
- })
895
- ];
896
- }
897
- };
898
- export {
899
- WorkItemRoutes,
900
- parseCreateWorkItem,
901
- parseUpdateWorkItem
249
+ /** Resolve the `(orgId, userId)` tenant or a ready-to-return error response. */
250
+ async #resolveTenant(c) {
251
+ await this.deps.auth.ensureUser(c);
252
+ const tenant = this.deps.auth.tenant(c);
253
+ if (!tenant) return { response: c.json({ error: "unauthorized" }, 401) };
254
+ if (!tenant.orgId) return { response: c.json({
255
+ error: "organization_required",
256
+ message: "The Factory board requires an organization."
257
+ }, 403) };
258
+ return {
259
+ orgId: tenant.orgId,
260
+ userId: tenant.userId
261
+ };
262
+ }
263
+ /**
264
+ * Resolve the tenant AND the org-owned project from the `:id` param. Work
265
+ * items hang off a project, so listing/creating requires the project to
266
+ * exist in the caller's org.
267
+ */
268
+ async #resolveProject(c) {
269
+ const tenant = await this.#resolveTenant(c);
270
+ if ("response" in tenant) return tenant;
271
+ const projectId = c.req.param("id");
272
+ if (!projectId || !UUID_RE.test(projectId)) return { response: c.json({ error: "Project not found" }, 404) };
273
+ const { projects } = this.deps;
274
+ await projects.ensureReady();
275
+ const project = await projects.get({
276
+ orgId: tenant.orgId,
277
+ id: projectId
278
+ });
279
+ if (!project) return { response: c.json({ error: "Project not found" }, 404) };
280
+ return {
281
+ ...tenant,
282
+ factoryProjectId: projectId,
283
+ defaultModelId: project.defaultModelId
284
+ };
285
+ }
286
+ /**
287
+ * Emit the audit events a successful work-item PATCH implies: always
288
+ * `updated`, plus `stage_moved` when the stages actually changed and one
289
+ * `run.started` per session role the patch introduced.
290
+ */
291
+ async #auditWorkItemPatch({ context, item, previous, patch }) {
292
+ const { audit } = this.deps;
293
+ const target = {
294
+ type: "work_item",
295
+ id: item.id,
296
+ name: item.title
297
+ };
298
+ await audit.emit({
299
+ context,
300
+ input: {
301
+ action: "factory.work_item.updated",
302
+ factoryProjectId: item.factoryProjectId,
303
+ targets: [target],
304
+ metadata: { fields: patchedFields(patch) }
305
+ }
306
+ });
307
+ if (patch.stages !== void 0 && (previous.stages.length !== item.stages.length || previous.stages.some((s, i) => s !== item.stages[i]))) await audit.emit({
308
+ context,
309
+ input: {
310
+ action: "factory.work_item.stage_moved",
311
+ factoryProjectId: item.factoryProjectId,
312
+ targets: [target],
313
+ metadata: {
314
+ from: previous.stages,
315
+ to: item.stages
316
+ }
317
+ }
318
+ });
319
+ const newRoles = Object.keys(item.sessions).filter((role) => !previous.sessionRoles.includes(role));
320
+ for (const role of newRoles) {
321
+ const session = item.sessions[role];
322
+ await audit.emit({
323
+ context,
324
+ input: {
325
+ action: "factory.run.started",
326
+ factoryProjectId: item.factoryProjectId,
327
+ targets: [target],
328
+ metadata: {
329
+ role,
330
+ branch: session?.branch,
331
+ threadId: session?.threadId,
332
+ sessionId: session?.sessionId
333
+ }
334
+ }
335
+ });
336
+ }
337
+ }
338
+ /** Build the Factory work-item routes as Mastra `apiRoutes`. */
339
+ routes() {
340
+ const { audit, workItems, queueHealth, transitionService, startCoordinator } = this.deps;
341
+ return [
342
+ registerApiRoute("/web/factory/projects/:id/work-items", {
343
+ method: "GET",
344
+ requiresAuth: false,
345
+ handler: async (c) => {
346
+ const resolved = await this.#resolveProject(loose(c));
347
+ if ("response" in resolved) return resolved.response;
348
+ await workItems.ensureReady();
349
+ const items = await workItems.list({
350
+ orgId: resolved.orgId,
351
+ factoryProjectId: resolved.factoryProjectId
352
+ });
353
+ return c.json({ workItems: items });
354
+ }
355
+ }),
356
+ registerApiRoute("/web/factory/projects/:id/metrics", {
357
+ method: "GET",
358
+ requiresAuth: false,
359
+ handler: async (c) => {
360
+ const resolved = await this.#resolveProject(loose(c));
361
+ if ("response" in resolved) return resolved.response;
362
+ const { windowStart, windowEnd } = parseMetricsRange(loose(c).req.query("from"), loose(c).req.query("to"), /* @__PURE__ */ new Date());
363
+ await workItems.ensureReady();
364
+ const items = await workItems.list({
365
+ orgId: resolved.orgId,
366
+ factoryProjectId: resolved.factoryProjectId
367
+ });
368
+ return c.json({ metrics: computeFactoryMetrics(items, {
369
+ windowStart,
370
+ windowEnd
371
+ }) });
372
+ }
373
+ }),
374
+ registerApiRoute("/web/factory/projects/:id/health/thresholds", {
375
+ method: "GET",
376
+ requiresAuth: false,
377
+ handler: async (c) => {
378
+ const resolved = await this.#resolveProject(loose(c));
379
+ if ("response" in resolved) return resolved.response;
380
+ await queueHealth.ensureReady();
381
+ const stored = await queueHealth.getConfig(resolved.orgId, resolved.factoryProjectId);
382
+ return c.json({ thresholds: thresholdsOrDefault(stored) });
383
+ }
384
+ }),
385
+ registerApiRoute("/web/factory/projects/:id/decisions", {
386
+ method: "GET",
387
+ requiresAuth: false,
388
+ handler: async (c) => {
389
+ const context = loose(c);
390
+ const resolved = await this.#resolveProject(context);
391
+ if ("response" in resolved) return resolved.response;
392
+ const cursorRaw = context.req.query("before");
393
+ const before = parseDecisionCursor(cursorRaw);
394
+ if (cursorRaw && !before) return c.json({ error: "invalid_cursor" }, 400);
395
+ await workItems.ensureReady();
396
+ const page = await workItems.listDeferredDecisionPage({
397
+ orgId: resolved.orgId,
398
+ factoryProjectId: resolved.factoryProjectId,
399
+ statuses: parseDecisionStatuses(context.req.query("statuses")),
400
+ before,
401
+ limit: parseDecisionLimit(context.req.query("limit"))
402
+ });
403
+ const last = page.decisions.at(-1);
404
+ return c.json({
405
+ decisions: page.decisions.map(decisionSummary),
406
+ ...page.hasMore && last ? { nextCursor: encodeDecisionCursor(last) } : {}
407
+ });
408
+ }
409
+ }),
410
+ registerApiRoute("/web/factory/projects/:id/decisions/:decisionId/retry", {
411
+ method: "POST",
412
+ requiresAuth: false,
413
+ handler: async (c) => {
414
+ const context = loose(c);
415
+ const resolved = await this.#resolveProject(context);
416
+ if ("response" in resolved) return resolved.response;
417
+ const decisionId = context.req.param("decisionId");
418
+ if (!decisionId || !UUID_RE.test(decisionId)) return c.json({ error: "invalid_decision_id" }, 422);
419
+ await workItems.ensureReady();
420
+ const decision = await workItems.retryDeferredDecision(resolved.orgId, resolved.factoryProjectId, decisionId, /* @__PURE__ */ new Date());
421
+ if (!decision) return c.json({ error: "decision_not_retryable" }, 409);
422
+ return c.json({ decision: decisionSummary(decision) });
423
+ }
424
+ }),
425
+ registerApiRoute("/web/factory/projects/:id/work-items", {
426
+ method: "POST",
427
+ requiresAuth: false,
428
+ handler: async (c) => {
429
+ const resolved = await this.#resolveProject(loose(c));
430
+ if ("response" in resolved) return resolved.response;
431
+ const body = await readJson(loose(c));
432
+ if (body === void 0) return c.json({ error: "Invalid JSON body" }, 400);
433
+ const input = parseCreateWorkItem(body);
434
+ if (!input) return c.json({ error: "invalid_work_item" }, 400);
435
+ if ((input.stages ?? ["intake"]).length !== 1 || (input.stages ?? ["intake"])[0] !== "intake") return c.json({
436
+ error: "governed_transition_required",
437
+ message: "New work items must enter through Factory intake."
438
+ }, 409);
439
+ await workItems.ensureReady();
440
+ try {
441
+ const result = await workItems.upsert({
442
+ orgId: resolved.orgId,
443
+ userId: resolved.userId,
444
+ factoryProjectId: resolved.factoryProjectId,
445
+ input,
446
+ reuseMode: "non-stage"
447
+ });
448
+ let item = result.item;
449
+ if (result.created) {
450
+ if (!transitionService) {
451
+ await workItems.delete({
452
+ orgId: resolved.orgId,
453
+ id: item.id
454
+ });
455
+ return c.json({ error: "factory_transitions_unavailable" }, 503);
456
+ }
457
+ const entered = await transitionService.transition({
458
+ orgId: resolved.orgId,
459
+ factoryProjectId: resolved.factoryProjectId,
460
+ workItemId: item.id,
461
+ board: item.externalSource?.type === "pull-request" ? "review" : "work",
462
+ stage: "intake",
463
+ expectedRevision: item.revision,
464
+ actor: {
465
+ type: "human",
466
+ id: resolved.userId
467
+ },
468
+ ingress: {
469
+ type: "human",
470
+ identity: `work-item:${item.id}:initial-entry`
471
+ },
472
+ cause: "work_item_created",
473
+ initialEntry: true
474
+ });
475
+ if (entered.status === "rejected") {
476
+ await workItems.delete({
477
+ orgId: resolved.orgId,
478
+ id: item.id
479
+ });
480
+ return c.json({
481
+ status: "rejected",
482
+ code: entered.code,
483
+ reason: entered.reason
484
+ }, 422);
485
+ }
486
+ item = await workItems.getForProject(resolved.orgId, resolved.factoryProjectId, item.id) ?? item;
487
+ await audit.emit({
488
+ context: loose(c),
489
+ input: {
490
+ action: "factory.work_item.created",
491
+ factoryProjectId: resolved.factoryProjectId,
492
+ targets: [{
493
+ type: "work_item",
494
+ id: item.id,
495
+ name: item.title
496
+ }],
497
+ metadata: {
498
+ externalSource: item.externalSource,
499
+ stages: item.stages
500
+ }
501
+ }
502
+ });
503
+ } else {
504
+ const { stages: _stages, sessions: _sessions, ...boundedPatch } = input;
505
+ await this.#auditWorkItemPatch({
506
+ context: loose(c),
507
+ item,
508
+ previous: result.previous,
509
+ patch: boundedPatch
510
+ });
511
+ }
512
+ return c.json({ workItem: item });
513
+ } catch (error) {
514
+ if (error instanceof WorkItemRelationError) return c.json({
515
+ error: error.code,
516
+ message: error.message
517
+ }, 400);
518
+ throw error;
519
+ }
520
+ }
521
+ }),
522
+ registerApiRoute("/web/factory/projects/:id/work-items/:workItemId/transition", {
523
+ method: "POST",
524
+ requiresAuth: false,
525
+ handler: async (c) => {
526
+ const resolved = await this.#resolveProject(loose(c));
527
+ if ("response" in resolved) return resolved.response;
528
+ const workItemId = loose(c).req.param("workItemId");
529
+ if (!workItemId || !UUID_RE.test(workItemId)) return c.json({ error: "Work item not found" }, 404);
530
+ const parsed = parseTransitionBody(await readJson(loose(c)));
531
+ if (!parsed) return c.json({ error: "invalid_transition_request" }, 400);
532
+ if (!transitionService) return c.json({ error: "factory_transition_unavailable" }, 503);
533
+ await workItems.ensureReady();
534
+ const result = await transitionService.transition({
535
+ ...parsed,
536
+ orgId: resolved.orgId,
537
+ factoryProjectId: resolved.factoryProjectId,
538
+ workItemId,
539
+ actor: {
540
+ type: "human",
541
+ id: resolved.userId
542
+ },
543
+ ingress: {
544
+ ...parsed.ingress,
545
+ identity: `human:${resolved.userId}:${parsed.ingress.identity}`
546
+ }
547
+ });
548
+ await audit.emit({
549
+ context: loose(c),
550
+ input: {
551
+ action: result.status === "accepted" ? "factory.work_item.stage_moved" : "factory.work_item.transition_rejected",
552
+ factoryProjectId: resolved.factoryProjectId,
553
+ targets: [{
554
+ type: "work_item",
555
+ id: workItemId
556
+ }],
557
+ metadata: {
558
+ transitionId: result.transitionId,
559
+ ingressType: parsed.ingress.type,
560
+ ruleSetVersion: transitionService.ruleSetVersion,
561
+ ...result.status === "accepted" ? {
562
+ to: result.stage,
563
+ revision: result.revision
564
+ } : {
565
+ code: result.code,
566
+ reason: result.reason
567
+ }
568
+ }
569
+ }
570
+ });
571
+ if (result.status === "accepted") return c.json({ result });
572
+ return c.json({ result }, result.code === "stale" ? 409 : 422);
573
+ }
574
+ }),
575
+ registerApiRoute("/web/factory/projects/:id/runs/start", {
576
+ method: "POST",
577
+ requiresAuth: false,
578
+ handler: async (c) => {
579
+ const resolved = await this.#resolveProject(loose(c));
580
+ if ("response" in resolved) return resolved.response;
581
+ if (!startCoordinator) return c.json({ error: "factory_start_unavailable" }, 503);
582
+ const input = parseStartBody(await readJson(loose(c)), resolved, resolved.factoryProjectId);
583
+ if (!input) return c.json({ error: "invalid_factory_start" }, 400);
584
+ input.requestContext = loose(c).get("requestContext");
585
+ input.defaultModelId = resolved.defaultModelId ?? void 0;
586
+ if (!input.workItem.id && ((input.workItem.input.stages ?? ["intake"]).length !== 1 || (input.workItem.input.stages ?? ["intake"])[0] !== "intake")) return c.json({
587
+ error: "governed_transition_required",
588
+ message: "Create the work item in Intake before starting it."
589
+ }, 409);
590
+ await workItems.ensureReady();
591
+ let prepared;
592
+ try {
593
+ prepared = await startCoordinator.prepare(input);
594
+ } catch (error) {
595
+ if (error instanceof FactoryStartTransitionError) return c.json({ result: error.result }, error.result.code === "stale" ? 409 : 422);
596
+ throw error;
597
+ }
598
+ await audit.emit({
599
+ context: loose(c),
600
+ input: {
601
+ action: "factory.run.started",
602
+ factoryProjectId: resolved.factoryProjectId,
603
+ targets: [{
604
+ type: "work_item",
605
+ id: prepared.workItemId
606
+ }],
607
+ metadata: {
608
+ role: input.workItem.role,
609
+ branch: prepared.branch,
610
+ threadId: prepared.threadId,
611
+ sessionId: prepared.sessionId,
612
+ bindingId: prepared.bindingId
613
+ }
614
+ }
615
+ });
616
+ return c.json({ prepared }, 202);
617
+ }
618
+ }),
619
+ registerApiRoute("/web/factory/work-items/:id", {
620
+ method: "PATCH",
621
+ requiresAuth: false,
622
+ handler: async (c) => {
623
+ const tenant = await this.#resolveTenant(loose(c));
624
+ if ("response" in tenant) return tenant.response;
625
+ const id = loose(c).req.param("id");
626
+ if (!id || !UUID_RE.test(id)) return c.json({ error: "Work item not found" }, 404);
627
+ const body = await readJson(loose(c));
628
+ if (body === void 0) return c.json({ error: "Invalid JSON body" }, 400);
629
+ const patch = parseUpdateWorkItem(body);
630
+ if (!patch) return c.json({ error: "invalid_work_item_patch" }, 400);
631
+ if (patch.stages !== void 0) return c.json({
632
+ error: "governed_transition_required",
633
+ message: "Use the Factory transition endpoint to move stages."
634
+ }, 409);
635
+ await workItems.ensureReady();
636
+ try {
637
+ const updated = await workItems.update({
638
+ orgId: tenant.orgId,
639
+ id,
640
+ userId: tenant.userId,
641
+ patch
642
+ });
643
+ if (!updated) return c.json({ error: "Work item not found" }, 404);
644
+ await this.#auditWorkItemPatch({
645
+ context: loose(c),
646
+ item: updated.item,
647
+ previous: updated.previous,
648
+ patch
649
+ });
650
+ return c.json({ workItem: updated.item });
651
+ } catch (error) {
652
+ if (error instanceof WorkItemRelationError) return c.json({
653
+ error: error.code,
654
+ message: error.message
655
+ }, 400);
656
+ throw error;
657
+ }
658
+ }
659
+ }),
660
+ registerApiRoute("/web/factory/work-items/:id", {
661
+ method: "DELETE",
662
+ requiresAuth: false,
663
+ handler: async (c) => {
664
+ const tenant = await this.#resolveTenant(loose(c));
665
+ if ("response" in tenant) return tenant.response;
666
+ const id = loose(c).req.param("id");
667
+ if (!id || !UUID_RE.test(id)) return c.json({ error: "Work item not found" }, 404);
668
+ await workItems.ensureReady();
669
+ const deleted = await workItems.delete({
670
+ orgId: tenant.orgId,
671
+ id
672
+ });
673
+ if (!deleted) return c.json({ error: "Work item not found" }, 404);
674
+ await audit.emit({
675
+ context: loose(c),
676
+ input: {
677
+ action: "factory.work_item.deleted",
678
+ factoryProjectId: deleted.factoryProjectId,
679
+ targets: [{
680
+ type: "work_item",
681
+ id: deleted.id,
682
+ name: deleted.title
683
+ }]
684
+ }
685
+ });
686
+ return c.json({ ok: true });
687
+ }
688
+ })
689
+ ];
690
+ }
902
691
  };
692
+ //#endregion
693
+ export { WorkItemRoutes, parseCreateWorkItem, parseUpdateWorkItem };
694
+
903
695
  //# sourceMappingURL=work-items.js.map