@stoneforge/quarry 0.1.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 (330) hide show
  1. package/LICENSE +13 -0
  2. package/README.md +160 -0
  3. package/dist/api/index.d.ts +8 -0
  4. package/dist/api/index.d.ts.map +1 -0
  5. package/dist/api/index.js +8 -0
  6. package/dist/api/index.js.map +1 -0
  7. package/dist/api/quarry-api.d.ts +268 -0
  8. package/dist/api/quarry-api.d.ts.map +1 -0
  9. package/dist/api/quarry-api.js +3905 -0
  10. package/dist/api/quarry-api.js.map +1 -0
  11. package/dist/api/types.d.ts +1359 -0
  12. package/dist/api/types.d.ts.map +1 -0
  13. package/dist/api/types.js +204 -0
  14. package/dist/api/types.js.map +1 -0
  15. package/dist/bin/sf.d.ts +3 -0
  16. package/dist/bin/sf.d.ts.map +1 -0
  17. package/dist/bin/sf.js +9 -0
  18. package/dist/bin/sf.js.map +1 -0
  19. package/dist/cli/commands/admin.d.ts +11 -0
  20. package/dist/cli/commands/admin.d.ts.map +1 -0
  21. package/dist/cli/commands/admin.js +465 -0
  22. package/dist/cli/commands/admin.js.map +1 -0
  23. package/dist/cli/commands/alias.d.ts +8 -0
  24. package/dist/cli/commands/alias.d.ts.map +1 -0
  25. package/dist/cli/commands/alias.js +70 -0
  26. package/dist/cli/commands/alias.js.map +1 -0
  27. package/dist/cli/commands/channel.d.ts +13 -0
  28. package/dist/cli/commands/channel.d.ts.map +1 -0
  29. package/dist/cli/commands/channel.js +680 -0
  30. package/dist/cli/commands/channel.js.map +1 -0
  31. package/dist/cli/commands/completion.d.ts +8 -0
  32. package/dist/cli/commands/completion.d.ts.map +1 -0
  33. package/dist/cli/commands/completion.js +87 -0
  34. package/dist/cli/commands/completion.js.map +1 -0
  35. package/dist/cli/commands/config.d.ts +12 -0
  36. package/dist/cli/commands/config.d.ts.map +1 -0
  37. package/dist/cli/commands/config.js +242 -0
  38. package/dist/cli/commands/config.js.map +1 -0
  39. package/dist/cli/commands/crud.d.ts +64 -0
  40. package/dist/cli/commands/crud.d.ts.map +1 -0
  41. package/dist/cli/commands/crud.js +805 -0
  42. package/dist/cli/commands/crud.js.map +1 -0
  43. package/dist/cli/commands/dep.d.ts +16 -0
  44. package/dist/cli/commands/dep.d.ts.map +1 -0
  45. package/dist/cli/commands/dep.js +499 -0
  46. package/dist/cli/commands/dep.js.map +1 -0
  47. package/dist/cli/commands/document.d.ts +12 -0
  48. package/dist/cli/commands/document.d.ts.map +1 -0
  49. package/dist/cli/commands/document.js +1039 -0
  50. package/dist/cli/commands/document.js.map +1 -0
  51. package/dist/cli/commands/embeddings.d.ts +12 -0
  52. package/dist/cli/commands/embeddings.d.ts.map +1 -0
  53. package/dist/cli/commands/embeddings.js +273 -0
  54. package/dist/cli/commands/embeddings.js.map +1 -0
  55. package/dist/cli/commands/entity.d.ts +16 -0
  56. package/dist/cli/commands/entity.d.ts.map +1 -0
  57. package/dist/cli/commands/entity.js +522 -0
  58. package/dist/cli/commands/entity.js.map +1 -0
  59. package/dist/cli/commands/gc.d.ts +10 -0
  60. package/dist/cli/commands/gc.d.ts.map +1 -0
  61. package/dist/cli/commands/gc.js +257 -0
  62. package/dist/cli/commands/gc.js.map +1 -0
  63. package/dist/cli/commands/help.d.ts +11 -0
  64. package/dist/cli/commands/help.d.ts.map +1 -0
  65. package/dist/cli/commands/help.js +169 -0
  66. package/dist/cli/commands/help.js.map +1 -0
  67. package/dist/cli/commands/history.d.ts +9 -0
  68. package/dist/cli/commands/history.d.ts.map +1 -0
  69. package/dist/cli/commands/history.js +160 -0
  70. package/dist/cli/commands/history.js.map +1 -0
  71. package/dist/cli/commands/identity.d.ts +18 -0
  72. package/dist/cli/commands/identity.d.ts.map +1 -0
  73. package/dist/cli/commands/identity.js +698 -0
  74. package/dist/cli/commands/identity.js.map +1 -0
  75. package/dist/cli/commands/inbox.d.ts +20 -0
  76. package/dist/cli/commands/inbox.d.ts.map +1 -0
  77. package/dist/cli/commands/inbox.js +493 -0
  78. package/dist/cli/commands/inbox.js.map +1 -0
  79. package/dist/cli/commands/init.d.ts +20 -0
  80. package/dist/cli/commands/init.d.ts.map +1 -0
  81. package/dist/cli/commands/init.js +144 -0
  82. package/dist/cli/commands/init.js.map +1 -0
  83. package/dist/cli/commands/install.d.ts +9 -0
  84. package/dist/cli/commands/install.d.ts.map +1 -0
  85. package/dist/cli/commands/install.js +200 -0
  86. package/dist/cli/commands/install.js.map +1 -0
  87. package/dist/cli/commands/library.d.ts +12 -0
  88. package/dist/cli/commands/library.d.ts.map +1 -0
  89. package/dist/cli/commands/library.js +665 -0
  90. package/dist/cli/commands/library.js.map +1 -0
  91. package/dist/cli/commands/message.d.ts +11 -0
  92. package/dist/cli/commands/message.d.ts.map +1 -0
  93. package/dist/cli/commands/message.js +608 -0
  94. package/dist/cli/commands/message.js.map +1 -0
  95. package/dist/cli/commands/plan.d.ts +17 -0
  96. package/dist/cli/commands/plan.d.ts.map +1 -0
  97. package/dist/cli/commands/plan.js +698 -0
  98. package/dist/cli/commands/plan.js.map +1 -0
  99. package/dist/cli/commands/playbook.d.ts +12 -0
  100. package/dist/cli/commands/playbook.d.ts.map +1 -0
  101. package/dist/cli/commands/playbook.js +730 -0
  102. package/dist/cli/commands/playbook.js.map +1 -0
  103. package/dist/cli/commands/reset.d.ts +12 -0
  104. package/dist/cli/commands/reset.d.ts.map +1 -0
  105. package/dist/cli/commands/reset.js +306 -0
  106. package/dist/cli/commands/reset.js.map +1 -0
  107. package/dist/cli/commands/serve.d.ts +11 -0
  108. package/dist/cli/commands/serve.d.ts.map +1 -0
  109. package/dist/cli/commands/serve.js +106 -0
  110. package/dist/cli/commands/serve.js.map +1 -0
  111. package/dist/cli/commands/stats.d.ts +8 -0
  112. package/dist/cli/commands/stats.d.ts.map +1 -0
  113. package/dist/cli/commands/stats.js +82 -0
  114. package/dist/cli/commands/stats.js.map +1 -0
  115. package/dist/cli/commands/sync.d.ts +14 -0
  116. package/dist/cli/commands/sync.d.ts.map +1 -0
  117. package/dist/cli/commands/sync.js +370 -0
  118. package/dist/cli/commands/sync.js.map +1 -0
  119. package/dist/cli/commands/task.d.ts +25 -0
  120. package/dist/cli/commands/task.d.ts.map +1 -0
  121. package/dist/cli/commands/task.js +1153 -0
  122. package/dist/cli/commands/task.js.map +1 -0
  123. package/dist/cli/commands/team.d.ts +13 -0
  124. package/dist/cli/commands/team.d.ts.map +1 -0
  125. package/dist/cli/commands/team.js +471 -0
  126. package/dist/cli/commands/team.js.map +1 -0
  127. package/dist/cli/commands/workflow.d.ts +16 -0
  128. package/dist/cli/commands/workflow.d.ts.map +1 -0
  129. package/dist/cli/commands/workflow.js +753 -0
  130. package/dist/cli/commands/workflow.js.map +1 -0
  131. package/dist/cli/completion.d.ts +28 -0
  132. package/dist/cli/completion.d.ts.map +1 -0
  133. package/dist/cli/completion.js +295 -0
  134. package/dist/cli/completion.js.map +1 -0
  135. package/dist/cli/db.d.ts +38 -0
  136. package/dist/cli/db.d.ts.map +1 -0
  137. package/dist/cli/db.js +90 -0
  138. package/dist/cli/db.js.map +1 -0
  139. package/dist/cli/formatter.d.ts +87 -0
  140. package/dist/cli/formatter.d.ts.map +1 -0
  141. package/dist/cli/formatter.js +464 -0
  142. package/dist/cli/formatter.js.map +1 -0
  143. package/dist/cli/index.d.ts +33 -0
  144. package/dist/cli/index.d.ts.map +1 -0
  145. package/dist/cli/index.js +38 -0
  146. package/dist/cli/index.js.map +1 -0
  147. package/dist/cli/parser.d.ts +45 -0
  148. package/dist/cli/parser.d.ts.map +1 -0
  149. package/dist/cli/parser.js +256 -0
  150. package/dist/cli/parser.js.map +1 -0
  151. package/dist/cli/plugin-loader.d.ts +39 -0
  152. package/dist/cli/plugin-loader.d.ts.map +1 -0
  153. package/dist/cli/plugin-loader.js +165 -0
  154. package/dist/cli/plugin-loader.js.map +1 -0
  155. package/dist/cli/plugin-registry.d.ts +50 -0
  156. package/dist/cli/plugin-registry.d.ts.map +1 -0
  157. package/dist/cli/plugin-registry.js +206 -0
  158. package/dist/cli/plugin-registry.js.map +1 -0
  159. package/dist/cli/plugin-types.d.ts +106 -0
  160. package/dist/cli/plugin-types.d.ts.map +1 -0
  161. package/dist/cli/plugin-types.js +103 -0
  162. package/dist/cli/plugin-types.js.map +1 -0
  163. package/dist/cli/runner.d.ts +35 -0
  164. package/dist/cli/runner.d.ts.map +1 -0
  165. package/dist/cli/runner.js +340 -0
  166. package/dist/cli/runner.js.map +1 -0
  167. package/dist/cli/suggest.d.ts +15 -0
  168. package/dist/cli/suggest.d.ts.map +1 -0
  169. package/dist/cli/suggest.js +49 -0
  170. package/dist/cli/suggest.js.map +1 -0
  171. package/dist/cli/types.d.ts +138 -0
  172. package/dist/cli/types.d.ts.map +1 -0
  173. package/dist/cli/types.js +63 -0
  174. package/dist/cli/types.js.map +1 -0
  175. package/dist/config/config.d.ts +86 -0
  176. package/dist/config/config.d.ts.map +1 -0
  177. package/dist/config/config.js +348 -0
  178. package/dist/config/config.js.map +1 -0
  179. package/dist/config/defaults.d.ts +66 -0
  180. package/dist/config/defaults.d.ts.map +1 -0
  181. package/dist/config/defaults.js +114 -0
  182. package/dist/config/defaults.js.map +1 -0
  183. package/dist/config/duration.d.ts +75 -0
  184. package/dist/config/duration.d.ts.map +1 -0
  185. package/dist/config/duration.js +190 -0
  186. package/dist/config/duration.js.map +1 -0
  187. package/dist/config/env.d.ts +67 -0
  188. package/dist/config/env.d.ts.map +1 -0
  189. package/dist/config/env.js +207 -0
  190. package/dist/config/env.js.map +1 -0
  191. package/dist/config/file.d.ts +97 -0
  192. package/dist/config/file.d.ts.map +1 -0
  193. package/dist/config/file.js +365 -0
  194. package/dist/config/file.js.map +1 -0
  195. package/dist/config/index.d.ts +35 -0
  196. package/dist/config/index.d.ts.map +1 -0
  197. package/dist/config/index.js +41 -0
  198. package/dist/config/index.js.map +1 -0
  199. package/dist/config/merge.d.ts +53 -0
  200. package/dist/config/merge.d.ts.map +1 -0
  201. package/dist/config/merge.js +226 -0
  202. package/dist/config/merge.js.map +1 -0
  203. package/dist/config/types.d.ts +257 -0
  204. package/dist/config/types.d.ts.map +1 -0
  205. package/dist/config/types.js +72 -0
  206. package/dist/config/types.js.map +1 -0
  207. package/dist/config/validation.d.ts +55 -0
  208. package/dist/config/validation.d.ts.map +1 -0
  209. package/dist/config/validation.js +251 -0
  210. package/dist/config/validation.js.map +1 -0
  211. package/dist/http/index.d.ts +8 -0
  212. package/dist/http/index.d.ts.map +1 -0
  213. package/dist/http/index.js +12 -0
  214. package/dist/http/index.js.map +1 -0
  215. package/dist/http/sync-handlers.d.ts +162 -0
  216. package/dist/http/sync-handlers.d.ts.map +1 -0
  217. package/dist/http/sync-handlers.js +271 -0
  218. package/dist/http/sync-handlers.js.map +1 -0
  219. package/dist/index.d.ts +25 -0
  220. package/dist/index.d.ts.map +1 -0
  221. package/dist/index.js +69 -0
  222. package/dist/index.js.map +1 -0
  223. package/dist/server/index.d.ts +34 -0
  224. package/dist/server/index.d.ts.map +1 -0
  225. package/dist/server/index.js +3329 -0
  226. package/dist/server/index.js.map +1 -0
  227. package/dist/server/static.d.ts +18 -0
  228. package/dist/server/static.d.ts.map +1 -0
  229. package/dist/server/static.js +71 -0
  230. package/dist/server/static.js.map +1 -0
  231. package/dist/server/ws/broadcaster.d.ts +8 -0
  232. package/dist/server/ws/broadcaster.d.ts.map +1 -0
  233. package/dist/server/ws/broadcaster.js +7 -0
  234. package/dist/server/ws/broadcaster.js.map +1 -0
  235. package/dist/server/ws/handler.d.ts +55 -0
  236. package/dist/server/ws/handler.d.ts.map +1 -0
  237. package/dist/server/ws/handler.js +160 -0
  238. package/dist/server/ws/handler.js.map +1 -0
  239. package/dist/services/blocked-cache.d.ts +297 -0
  240. package/dist/services/blocked-cache.d.ts.map +1 -0
  241. package/dist/services/blocked-cache.js +755 -0
  242. package/dist/services/blocked-cache.js.map +1 -0
  243. package/dist/services/dependency.d.ts +205 -0
  244. package/dist/services/dependency.d.ts.map +1 -0
  245. package/dist/services/dependency.js +566 -0
  246. package/dist/services/dependency.js.map +1 -0
  247. package/dist/services/embeddings/fusion.d.ts +33 -0
  248. package/dist/services/embeddings/fusion.d.ts.map +1 -0
  249. package/dist/services/embeddings/fusion.js +34 -0
  250. package/dist/services/embeddings/fusion.js.map +1 -0
  251. package/dist/services/embeddings/index.d.ts +12 -0
  252. package/dist/services/embeddings/index.d.ts.map +1 -0
  253. package/dist/services/embeddings/index.js +10 -0
  254. package/dist/services/embeddings/index.js.map +1 -0
  255. package/dist/services/embeddings/local-provider.d.ts +31 -0
  256. package/dist/services/embeddings/local-provider.d.ts.map +1 -0
  257. package/dist/services/embeddings/local-provider.js +80 -0
  258. package/dist/services/embeddings/local-provider.js.map +1 -0
  259. package/dist/services/embeddings/service.d.ts +76 -0
  260. package/dist/services/embeddings/service.d.ts.map +1 -0
  261. package/dist/services/embeddings/service.js +153 -0
  262. package/dist/services/embeddings/service.js.map +1 -0
  263. package/dist/services/embeddings/types.d.ts +70 -0
  264. package/dist/services/embeddings/types.d.ts.map +1 -0
  265. package/dist/services/embeddings/types.js +8 -0
  266. package/dist/services/embeddings/types.js.map +1 -0
  267. package/dist/services/id-length-cache.d.ts +156 -0
  268. package/dist/services/id-length-cache.d.ts.map +1 -0
  269. package/dist/services/id-length-cache.js +197 -0
  270. package/dist/services/id-length-cache.js.map +1 -0
  271. package/dist/services/inbox.d.ts +147 -0
  272. package/dist/services/inbox.d.ts.map +1 -0
  273. package/dist/services/inbox.js +428 -0
  274. package/dist/services/inbox.js.map +1 -0
  275. package/dist/services/index.d.ts +10 -0
  276. package/dist/services/index.d.ts.map +1 -0
  277. package/dist/services/index.js +10 -0
  278. package/dist/services/index.js.map +1 -0
  279. package/dist/services/priority-service.d.ts +145 -0
  280. package/dist/services/priority-service.d.ts.map +1 -0
  281. package/dist/services/priority-service.js +272 -0
  282. package/dist/services/priority-service.js.map +1 -0
  283. package/dist/services/search-utils.d.ts +47 -0
  284. package/dist/services/search-utils.d.ts.map +1 -0
  285. package/dist/services/search-utils.js +83 -0
  286. package/dist/services/search-utils.js.map +1 -0
  287. package/dist/sync/hash.d.ts +48 -0
  288. package/dist/sync/hash.d.ts.map +1 -0
  289. package/dist/sync/hash.js +136 -0
  290. package/dist/sync/hash.js.map +1 -0
  291. package/dist/sync/index.d.ts +11 -0
  292. package/dist/sync/index.d.ts.map +1 -0
  293. package/dist/sync/index.js +16 -0
  294. package/dist/sync/index.js.map +1 -0
  295. package/dist/sync/merge.d.ts +80 -0
  296. package/dist/sync/merge.d.ts.map +1 -0
  297. package/dist/sync/merge.js +310 -0
  298. package/dist/sync/merge.js.map +1 -0
  299. package/dist/sync/serialization.d.ts +132 -0
  300. package/dist/sync/serialization.d.ts.map +1 -0
  301. package/dist/sync/serialization.js +306 -0
  302. package/dist/sync/serialization.js.map +1 -0
  303. package/dist/sync/service.d.ts +102 -0
  304. package/dist/sync/service.d.ts.map +1 -0
  305. package/dist/sync/service.js +493 -0
  306. package/dist/sync/service.js.map +1 -0
  307. package/dist/sync/types.d.ts +275 -0
  308. package/dist/sync/types.d.ts.map +1 -0
  309. package/dist/sync/types.js +76 -0
  310. package/dist/sync/types.js.map +1 -0
  311. package/dist/systems/identity.d.ts +479 -0
  312. package/dist/systems/identity.d.ts.map +1 -0
  313. package/dist/systems/identity.js +817 -0
  314. package/dist/systems/identity.js.map +1 -0
  315. package/dist/systems/index.d.ts +8 -0
  316. package/dist/systems/index.d.ts.map +1 -0
  317. package/dist/systems/index.js +29 -0
  318. package/dist/systems/index.js.map +1 -0
  319. package/package.json +121 -0
  320. package/web/assets/charts-vendor-D1YcbGux.js +55 -0
  321. package/web/assets/dnd-vendor-DmxE-_ZH.js +5 -0
  322. package/web/assets/editor-vendor-BxraAWts.js +279 -0
  323. package/web/assets/index-B77vv208.js +341 -0
  324. package/web/assets/index-CF_XnVLh.css +1 -0
  325. package/web/assets/router-vendor-BCKpRBrB.js +41 -0
  326. package/web/assets/ui-vendor-DUahGnbT.js +45 -0
  327. package/web/assets/utils-vendor-CfYKiENT.js +813 -0
  328. package/web/favicon.ico +0 -0
  329. package/web/index.html +23 -0
  330. package/web/logo.png +0 -0
@@ -0,0 +1,3329 @@
1
+ /**
2
+ * Stoneforge Platform Server
3
+ *
4
+ * HTTP + WebSocket server for the Stoneforge web platform.
5
+ * Built with Hono for fast, minimal overhead.
6
+ *
7
+ * Exports `createQuarryApp` (builds the Hono app + services) and
8
+ * `startQuarryServer` (creates the app and starts listening with
9
+ * dual-runtime Bun/Node support).
10
+ */
11
+ import { resolve, dirname, extname } from 'node:path';
12
+ import { mkdirSync } from 'node:fs';
13
+ import { registerStaticMiddleware } from './static.js';
14
+ import { mkdir, readdir, unlink, stat } from 'node:fs/promises';
15
+ import { createHash } from 'node:crypto';
16
+ import { Hono } from 'hono';
17
+ import { cors } from 'hono/cors';
18
+ // Core types and factory functions
19
+ import { createTask, createDocument, createWorkflowFromPlaybook, createWorkflow, discoverPlaybookFiles, loadPlaybookFromFile, createPlaybook, createEntity, createTeam, getDirectReports, getManagementChain, detectReportingCycle, } from '@stoneforge/core';
20
+ // Storage layer
21
+ import { createStorage, initializeSchema } from '@stoneforge/storage';
22
+ // SDK - API and services (relative imports since we're inside the quarry package)
23
+ import { createQuarryAPI } from '../api/quarry-api.js';
24
+ import { createSyncService } from '../sync/service.js';
25
+ import { createInboxService } from '../services/inbox.js';
26
+ // Shared routes for collaborate features
27
+ import { createElementsRoutes, createChannelRoutes, createMessageRoutes, createLibraryRoutes, createDocumentRoutes, createPlanRoutes, } from '@stoneforge/shared-routes';
28
+ import { initializeBroadcaster } from './ws/broadcaster.js';
29
+ import { handleOpen, handleMessage, handleClose, handleError, getClientCount, broadcastInboxEvent } from './ws/handler.js';
30
+ // ============================================================================
31
+ // createQuarryApp
32
+ // ============================================================================
33
+ export function createQuarryApp(options = {}) {
34
+ const PORT = options.port ?? parseInt(process.env.PORT || '3456', 10);
35
+ const HOST = options.host ?? (process.env.HOST || 'localhost');
36
+ // Database path - defaults to .stoneforge/stoneforge.db in current working directory
37
+ const PROJECT_ROOT = process.cwd();
38
+ const DEFAULT_DB_PATH = resolve(PROJECT_ROOT, '.stoneforge/stoneforge.db');
39
+ const DB_PATH = options.dbPath ?? (process.env.STONEFORGE_DB_PATH || DEFAULT_DB_PATH);
40
+ // Uploads directory - defaults based on dbPath directory, falling back to PROJECT_ROOT
41
+ const UPLOADS_DIR = DB_PATH === ':memory:'
42
+ ? resolve(PROJECT_ROOT, '.stoneforge/uploads')
43
+ : resolve(dirname(DB_PATH), 'uploads');
44
+ const MAX_UPLOAD_SIZE = 10 * 1024 * 1024; // 10MB
45
+ const ALLOWED_MIME_TYPES = [
46
+ 'image/jpeg',
47
+ 'image/png',
48
+ 'image/gif',
49
+ 'image/webp',
50
+ 'image/svg+xml',
51
+ ];
52
+ const MIME_TO_EXT = {
53
+ 'image/jpeg': '.jpg',
54
+ 'image/png': '.png',
55
+ 'image/gif': '.gif',
56
+ 'image/webp': '.webp',
57
+ 'image/svg+xml': '.svg',
58
+ };
59
+ // ============================================================================
60
+ // Initialize API
61
+ // ============================================================================
62
+ let api;
63
+ let syncService;
64
+ let inboxService;
65
+ let storageBackend;
66
+ try {
67
+ if (DB_PATH !== ':memory:') {
68
+ mkdirSync(dirname(DB_PATH), { recursive: true });
69
+ }
70
+ storageBackend = createStorage({ path: DB_PATH });
71
+ initializeSchema(storageBackend);
72
+ api = createQuarryAPI(storageBackend);
73
+ syncService = createSyncService(storageBackend);
74
+ inboxService = createInboxService(storageBackend);
75
+ console.log(`[stoneforge] Connected to database: ${DB_PATH}`);
76
+ }
77
+ catch (error) {
78
+ throw new Error(`Failed to initialize database: ${error instanceof Error ? error.message : String(error)}`);
79
+ }
80
+ // ============================================================================
81
+ // Initialize Event Broadcaster
82
+ // ============================================================================
83
+ const broadcaster = initializeBroadcaster(api);
84
+ broadcaster.start().catch((err) => {
85
+ console.error('[stoneforge] Failed to start event broadcaster:', err);
86
+ });
87
+ // ============================================================================
88
+ // Create Hono App
89
+ // ============================================================================
90
+ const app = new Hono();
91
+ // CORS middleware - allow web app to connect
92
+ const corsOrigins = options.corsOrigins ?? [
93
+ `http://${HOST}:${PORT}`,
94
+ `http://127.0.0.1:${PORT}`,
95
+ `http://localhost:${PORT}`,
96
+ ];
97
+ app.use('*', cors({
98
+ origin: corsOrigins,
99
+ allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
100
+ allowHeaders: ['Content-Type', 'Authorization'],
101
+ credentials: true,
102
+ }));
103
+ // ============================================================================
104
+ // Register Shared Collaborate Routes
105
+ // ============================================================================
106
+ // Create services object for shared routes
107
+ const collaborateServices = {
108
+ api,
109
+ inboxService,
110
+ storageBackend,
111
+ broadcastInboxEvent,
112
+ };
113
+ // Register all collaborate routes from shared package
114
+ app.route('/', createElementsRoutes(collaborateServices));
115
+ app.route('/', createChannelRoutes(collaborateServices));
116
+ app.route('/', createMessageRoutes(collaborateServices));
117
+ app.route('/', createLibraryRoutes(collaborateServices));
118
+ app.route('/', createDocumentRoutes(collaborateServices));
119
+ app.route('/', createPlanRoutes(collaborateServices));
120
+ // ============================================================================
121
+ // Health Check Endpoint
122
+ // ============================================================================
123
+ app.get('/api/health', (c) => {
124
+ return c.json({
125
+ status: 'ok',
126
+ timestamp: new Date().toISOString(),
127
+ database: DB_PATH,
128
+ websocket: {
129
+ clients: getClientCount(),
130
+ broadcasting: broadcaster.listenerCount > 0,
131
+ },
132
+ });
133
+ });
134
+ // ============================================================================
135
+ // Stats Endpoint
136
+ // ============================================================================
137
+ app.get('/api/stats', async (c) => {
138
+ try {
139
+ const stats = await api.stats();
140
+ return c.json(stats);
141
+ }
142
+ catch (error) {
143
+ console.error('[stoneforge] Failed to get stats:', error);
144
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get stats' } }, 500);
145
+ }
146
+ });
147
+ // ============================================================================
148
+ // Task Enrichment Helper (TB83)
149
+ // ============================================================================
150
+ /**
151
+ * Enriches tasks with dependency and attachment counts.
152
+ * Used by multiple endpoints for TB83 rich task display.
153
+ */
154
+ function enrichTasksWithCounts(tasks) {
155
+ if (tasks.length === 0)
156
+ return tasks;
157
+ // Get all dependencies efficiently using a single query
158
+ const allDependencies = storageBackend.query('SELECT blocked_id, blocker_id, type FROM dependencies');
159
+ // Build maps for quick lookup
160
+ const blocksCountMap = new Map();
161
+ const blockedByCountMap = new Map();
162
+ const attachmentCountMap = new Map();
163
+ for (const dep of allDependencies) {
164
+ const depType = dep.type;
165
+ const blockedId = dep.blocked_id;
166
+ const blockerId = dep.blocker_id;
167
+ if (depType === 'blocks' || depType === 'awaits') {
168
+ blocksCountMap.set(blockerId, (blocksCountMap.get(blockerId) || 0) + 1);
169
+ blockedByCountMap.set(blockedId, (blockedByCountMap.get(blockedId) || 0) + 1);
170
+ }
171
+ else if (depType === 'references') {
172
+ attachmentCountMap.set(blockedId, (attachmentCountMap.get(blockedId) || 0) + 1);
173
+ }
174
+ }
175
+ // Enrich tasks with counts
176
+ return tasks.map((task) => {
177
+ const taskId = task.id;
178
+ return {
179
+ ...task,
180
+ _attachmentCount: attachmentCountMap.get(taskId) || 0,
181
+ _blocksCount: blocksCountMap.get(taskId) || 0,
182
+ _blockedByCount: blockedByCountMap.get(taskId) || 0,
183
+ };
184
+ });
185
+ }
186
+ // ============================================================================
187
+ // Tasks Endpoints
188
+ // ============================================================================
189
+ app.get('/api/tasks', async (c) => {
190
+ try {
191
+ const url = new URL(c.req.url);
192
+ // Parse query parameters
193
+ const statusParam = url.searchParams.get('status');
194
+ const priorityParam = url.searchParams.get('priority');
195
+ const assigneeParam = url.searchParams.get('assignee');
196
+ const tagsParam = url.searchParams.get('tags');
197
+ const limitParam = url.searchParams.get('limit');
198
+ const offsetParam = url.searchParams.get('offset');
199
+ const orderByParam = url.searchParams.get('orderBy');
200
+ const orderDirParam = url.searchParams.get('orderDir');
201
+ const searchParam = url.searchParams.get('search');
202
+ // Build filter
203
+ const filter = {
204
+ type: 'task',
205
+ };
206
+ if (statusParam) {
207
+ // Support comma-separated statuses
208
+ filter.status = statusParam.includes(',') ? statusParam.split(',') : statusParam;
209
+ }
210
+ if (priorityParam) {
211
+ const priorities = priorityParam.split(',').map(p => parseInt(p, 10)).filter(p => !isNaN(p));
212
+ filter.priority = priorities.length === 1 ? priorities[0] : priorities;
213
+ }
214
+ if (assigneeParam) {
215
+ filter.assignee = assigneeParam;
216
+ }
217
+ if (tagsParam) {
218
+ filter.tags = tagsParam.split(',');
219
+ }
220
+ if (limitParam) {
221
+ filter.limit = parseInt(limitParam, 10);
222
+ }
223
+ else {
224
+ filter.limit = 50; // Default page size
225
+ }
226
+ if (offsetParam) {
227
+ filter.offset = parseInt(offsetParam, 10);
228
+ }
229
+ if (orderByParam) {
230
+ filter.orderBy = orderByParam;
231
+ }
232
+ else {
233
+ filter.orderBy = 'updated_at';
234
+ }
235
+ if (orderDirParam) {
236
+ filter.orderDir = orderDirParam;
237
+ }
238
+ else {
239
+ filter.orderDir = 'desc';
240
+ }
241
+ // If search param is provided, use the search API
242
+ if (searchParam && searchParam.trim()) {
243
+ const searchResults = await api.search(searchParam.trim(), filter);
244
+ const limit = filter.limit || 50;
245
+ const offset = filter.offset || 0;
246
+ const slicedResults = searchResults.slice(offset, offset + limit);
247
+ return c.json({
248
+ data: slicedResults,
249
+ total: searchResults.length,
250
+ limit,
251
+ offset,
252
+ });
253
+ }
254
+ const result = await api.listPaginated(filter);
255
+ return c.json(result);
256
+ }
257
+ catch (error) {
258
+ console.error('[stoneforge] Failed to get tasks:', error);
259
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get tasks' } }, 500);
260
+ }
261
+ });
262
+ app.get('/api/tasks/ready', async (c) => {
263
+ try {
264
+ const tasks = await api.ready();
265
+ // TB83: Enrich ready tasks with counts for rich display
266
+ const enrichedTasks = enrichTasksWithCounts(tasks);
267
+ return c.json(enrichedTasks);
268
+ }
269
+ catch (error) {
270
+ console.error('[stoneforge] Failed to get ready tasks:', error);
271
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get ready tasks' } }, 500);
272
+ }
273
+ });
274
+ app.get('/api/tasks/blocked', async (c) => {
275
+ try {
276
+ const tasks = await api.blocked();
277
+ // TB83: Enrich blocked tasks with counts for rich display
278
+ const enrichedTasks = enrichTasksWithCounts(tasks);
279
+ return c.json(enrichedTasks);
280
+ }
281
+ catch (error) {
282
+ console.error('[stoneforge] Failed to get blocked tasks:', error);
283
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get blocked tasks' } }, 500);
284
+ }
285
+ });
286
+ app.get('/api/tasks/in-progress', async (c) => {
287
+ try {
288
+ // Get tasks with in_progress status, sorted by updated_at desc
289
+ const tasks = await api.list({
290
+ type: 'task',
291
+ status: 'in_progress',
292
+ orderBy: 'updated_at',
293
+ orderDir: 'desc',
294
+ });
295
+ return c.json(tasks);
296
+ }
297
+ catch (error) {
298
+ console.error('[stoneforge] Failed to get in-progress tasks:', error);
299
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get in-progress tasks' } }, 500);
300
+ }
301
+ });
302
+ app.get('/api/tasks/completed', async (c) => {
303
+ try {
304
+ const url = new URL(c.req.url);
305
+ const limitParam = url.searchParams.get('limit');
306
+ const offsetParam = url.searchParams.get('offset');
307
+ const afterParam = url.searchParams.get('after'); // ISO date string for date filtering
308
+ // Get tasks with closed status, sorted by updated_at desc
309
+ // The API accepts TaskFilter when type is 'task', but TypeScript signature is ElementFilter
310
+ // Note: The actual status value is 'closed' (not 'completed') per src/types/task.ts
311
+ const filter = {
312
+ type: 'task',
313
+ status: ['closed'],
314
+ orderBy: 'updated_at',
315
+ orderDir: 'desc',
316
+ limit: limitParam ? parseInt(limitParam, 10) : 20,
317
+ };
318
+ if (offsetParam) {
319
+ filter.offset = parseInt(offsetParam, 10);
320
+ }
321
+ // Note: 'after' date filtering needs to be done post-query since the API
322
+ // may not support date filtering directly on updated_at
323
+ let tasks = await api.list(filter);
324
+ // Save the fetched count before filtering to determine if there are more pages
325
+ const fetchedCount = tasks.length;
326
+ // Apply date filter if provided
327
+ if (afterParam) {
328
+ const afterDate = new Date(afterParam);
329
+ tasks = tasks.filter((task) => new Date(task.updatedAt) >= afterDate);
330
+ }
331
+ // Return with total count for pagination info
332
+ // hasMore is based on whether we got a full page from the DB (before date filtering)
333
+ return c.json({
334
+ items: tasks,
335
+ hasMore: fetchedCount === filter.limit,
336
+ });
337
+ }
338
+ catch (error) {
339
+ console.error('[stoneforge] Failed to get completed tasks:', error);
340
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get completed tasks' } }, 500);
341
+ }
342
+ });
343
+ app.get('/api/tasks/:id', async (c) => {
344
+ try {
345
+ const id = c.req.param('id');
346
+ const url = new URL(c.req.url);
347
+ // Parse hydration options from query params
348
+ const hydrateDescription = url.searchParams.get('hydrate.description') === 'true';
349
+ const hydrateDesign = url.searchParams.get('hydrate.design') === 'true';
350
+ const hydrate = (hydrateDescription || hydrateDesign)
351
+ ? { description: hydrateDescription, design: hydrateDesign }
352
+ : undefined;
353
+ const task = await api.get(id, hydrate ? { hydrate } : undefined);
354
+ if (!task) {
355
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
356
+ }
357
+ // Verify it's actually a task
358
+ if (task.type !== 'task') {
359
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
360
+ }
361
+ // Fetch dependencies and dependents for the task detail view
362
+ const [dependencies, dependents] = await Promise.all([
363
+ api.getDependencies(id),
364
+ api.getDependents(id),
365
+ ]);
366
+ return c.json({
367
+ ...task,
368
+ _dependencies: dependencies,
369
+ _dependents: dependents,
370
+ });
371
+ }
372
+ catch (error) {
373
+ console.error('[stoneforge] Failed to get task:', error);
374
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get task' } }, 500);
375
+ }
376
+ });
377
+ app.post('/api/tasks', async (c) => {
378
+ try {
379
+ const body = await c.req.json();
380
+ // Validate required fields
381
+ if (!body.title || typeof body.title !== 'string') {
382
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'title is required' } }, 400);
383
+ }
384
+ if (!body.createdBy || typeof body.createdBy !== 'string') {
385
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'createdBy is required' } }, 400);
386
+ }
387
+ // Handle description field - creates linked Document (TB124)
388
+ let descriptionRef = body.descriptionRef;
389
+ if (body.description !== undefined && body.description.trim().length > 0 && !descriptionRef) {
390
+ const docInput = {
391
+ contentType: 'markdown',
392
+ content: body.description,
393
+ createdBy: body.createdBy,
394
+ tags: ['task-description'],
395
+ };
396
+ const newDoc = await createDocument(docInput);
397
+ const docWithTitle = { ...newDoc, title: `Description for task ${body.title}` };
398
+ const createdDoc = await api.create(docWithTitle);
399
+ descriptionRef = createdDoc.id;
400
+ }
401
+ // Build CreateTaskInput from request body
402
+ const taskInput = {
403
+ title: body.title,
404
+ createdBy: body.createdBy,
405
+ ...(body.status !== undefined && { status: body.status }),
406
+ ...(body.priority !== undefined && { priority: body.priority }),
407
+ ...(body.complexity !== undefined && { complexity: body.complexity }),
408
+ ...(body.taskType !== undefined && { taskType: body.taskType }),
409
+ ...(body.assignee !== undefined && { assignee: body.assignee }),
410
+ ...(body.owner !== undefined && { owner: body.owner }),
411
+ ...(body.deadline !== undefined && { deadline: body.deadline }),
412
+ ...(body.scheduledFor !== undefined && { scheduledFor: body.scheduledFor }),
413
+ ...(body.tags !== undefined && { tags: body.tags }),
414
+ ...(descriptionRef !== undefined && { descriptionRef }),
415
+ ...(body.acceptanceCriteria !== undefined && { acceptanceCriteria: body.acceptanceCriteria }),
416
+ };
417
+ const task = await createTask(taskInput);
418
+ const created = await api.create(task);
419
+ return c.json(created);
420
+ }
421
+ catch (error) {
422
+ if (error.code === 'VALIDATION_ERROR') {
423
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
424
+ }
425
+ console.error('[stoneforge] Failed to create task:', error);
426
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to create task' } }, 500);
427
+ }
428
+ });
429
+ // Bulk update tasks - MUST be before /:id route to avoid matching "bulk" as an id
430
+ app.patch('/api/tasks/bulk', async (c) => {
431
+ try {
432
+ const body = await c.req.json();
433
+ // Validate request structure
434
+ if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) {
435
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'ids must be a non-empty array' } }, 400);
436
+ }
437
+ if (!body.updates || typeof body.updates !== 'object') {
438
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'updates must be an object' } }, 400);
439
+ }
440
+ const ids = body.ids;
441
+ // Extract allowed updates
442
+ const updates = {};
443
+ const allowedFields = [
444
+ 'status', 'priority', 'complexity', 'taskType',
445
+ 'assignee', 'owner', 'deadline', 'scheduledFor', 'tags'
446
+ ];
447
+ for (const field of allowedFields) {
448
+ if (body.updates[field] !== undefined) {
449
+ updates[field] = body.updates[field];
450
+ }
451
+ }
452
+ if (Object.keys(updates).length === 0) {
453
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'No valid fields to update' } }, 400);
454
+ }
455
+ // Update each task
456
+ const results = [];
457
+ for (const id of ids) {
458
+ try {
459
+ const existing = await api.get(id);
460
+ if (!existing || existing.type !== 'task') {
461
+ results.push({ id, success: false, error: 'Task not found' });
462
+ continue;
463
+ }
464
+ await api.update(id, updates);
465
+ results.push({ id, success: true });
466
+ }
467
+ catch (error) {
468
+ results.push({ id, success: false, error: error.message });
469
+ }
470
+ }
471
+ const successCount = results.filter(r => r.success).length;
472
+ const failureCount = results.filter(r => !r.success).length;
473
+ return c.json({
474
+ updated: successCount,
475
+ failed: failureCount,
476
+ results,
477
+ });
478
+ }
479
+ catch (error) {
480
+ console.error('[stoneforge] Failed to bulk update tasks:', error);
481
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to bulk update tasks' } }, 500);
482
+ }
483
+ });
484
+ // Bulk delete tasks - Uses POST with action parameter for better proxy compatibility
485
+ app.post('/api/tasks/bulk-delete', async (c) => {
486
+ console.log('[stoneforge] Bulk delete request received');
487
+ try {
488
+ const body = await c.req.json();
489
+ console.log('[stoneforge] Bulk delete body:', JSON.stringify(body));
490
+ // Validate request structure
491
+ if (!body.ids || !Array.isArray(body.ids) || body.ids.length === 0) {
492
+ console.log('[stoneforge] Bulk delete validation failed: ids must be a non-empty array');
493
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'ids must be a non-empty array' } }, 400);
494
+ }
495
+ const ids = body.ids;
496
+ console.log('[stoneforge] Deleting tasks:', ids);
497
+ // Delete each task
498
+ const results = [];
499
+ for (const id of ids) {
500
+ try {
501
+ const existing = await api.get(id);
502
+ if (!existing || existing.type !== 'task') {
503
+ console.log(`[stoneforge] Task not found: ${id}`);
504
+ results.push({ id, success: false, error: 'Task not found' });
505
+ continue;
506
+ }
507
+ console.log(`[stoneforge] Deleting task: ${id}`);
508
+ await api.delete(id);
509
+ console.log(`[stoneforge] Successfully deleted task: ${id}`);
510
+ results.push({ id, success: true });
511
+ }
512
+ catch (error) {
513
+ console.error(`[stoneforge] Error deleting task ${id}:`, error);
514
+ results.push({ id, success: false, error: error.message });
515
+ }
516
+ }
517
+ const successCount = results.filter(r => r.success).length;
518
+ const failureCount = results.filter(r => !r.success).length;
519
+ console.log(`[stoneforge] Bulk delete complete: ${successCount} deleted, ${failureCount} failed`);
520
+ return c.json({
521
+ deleted: successCount,
522
+ failed: failureCount,
523
+ results,
524
+ });
525
+ }
526
+ catch (error) {
527
+ console.error('[stoneforge] Failed to bulk delete tasks:', error);
528
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to bulk delete tasks' } }, 500);
529
+ }
530
+ });
531
+ app.patch('/api/tasks/:id', async (c) => {
532
+ try {
533
+ const id = c.req.param('id');
534
+ const body = await c.req.json();
535
+ // First verify it's a task
536
+ const existing = await api.get(id);
537
+ if (!existing) {
538
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
539
+ }
540
+ if (existing.type !== 'task') {
541
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
542
+ }
543
+ // Extract allowed updates (prevent changing immutable fields)
544
+ const updates = {};
545
+ const allowedFields = [
546
+ 'title', 'status', 'priority', 'complexity', 'taskType',
547
+ 'assignee', 'owner', 'deadline', 'scheduledFor', 'tags', 'metadata'
548
+ ];
549
+ for (const field of allowedFields) {
550
+ if (body[field] !== undefined) {
551
+ updates[field] = body[field];
552
+ }
553
+ }
554
+ // Handle description field - creates or updates linked Document (TB124)
555
+ if (body.description !== undefined) {
556
+ const task = existing;
557
+ if (task.descriptionRef) {
558
+ // Update existing description document
559
+ const descDoc = await api.get(task.descriptionRef);
560
+ if (descDoc && descDoc.type === 'document') {
561
+ await api.update(task.descriptionRef, {
562
+ content: body.description,
563
+ });
564
+ }
565
+ }
566
+ else if (body.description.trim().length > 0) {
567
+ // Create new description document and link it
568
+ const docInput = {
569
+ contentType: 'markdown',
570
+ content: body.description,
571
+ createdBy: task.createdBy,
572
+ tags: ['task-description'],
573
+ };
574
+ const newDoc = await createDocument(docInput);
575
+ const docWithTitle = { ...newDoc, title: `Description for task ${id}` };
576
+ const createdDoc = await api.create(docWithTitle);
577
+ updates.descriptionRef = createdDoc.id;
578
+ }
579
+ }
580
+ // Update the task
581
+ const updated = await api.update(id, updates);
582
+ return c.json(updated);
583
+ }
584
+ catch (error) {
585
+ if (error.code === 'NOT_FOUND') {
586
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
587
+ }
588
+ if (error.code === 'CONCURRENT_MODIFICATION') {
589
+ return c.json({ error: { code: 'CONFLICT', message: 'Task was modified by another process' } }, 409);
590
+ }
591
+ if (error.code === 'VALIDATION_ERROR') {
592
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
593
+ }
594
+ console.error('[stoneforge] Failed to update task:', error);
595
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to update task' } }, 500);
596
+ }
597
+ });
598
+ app.delete('/api/tasks/:id', async (c) => {
599
+ try {
600
+ const id = c.req.param('id');
601
+ // First verify it's a task
602
+ const existing = await api.get(id);
603
+ if (!existing) {
604
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
605
+ }
606
+ if (existing.type !== 'task') {
607
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
608
+ }
609
+ // TB121/TB122: Check if task is in a plan or workflow and would be the last one
610
+ const parentDeps = await api.getDependencies(id, ['parent-child']);
611
+ for (const dep of parentDeps) {
612
+ const parent = await api.get(dep.blockerId);
613
+ if (parent) {
614
+ if (parent.type === 'plan') {
615
+ // Check if this is the last task in the plan
616
+ const planTasks = await api.getTasksInPlan(dep.blockerId);
617
+ if (planTasks.length === 1 && planTasks[0].id === id) {
618
+ return c.json({
619
+ error: {
620
+ code: 'LAST_TASK',
621
+ message: 'Cannot delete the last task in a plan. Plans must have at least one task.'
622
+ }
623
+ }, 400);
624
+ }
625
+ }
626
+ else if (parent.type === 'workflow') {
627
+ // Check if this is the last task in the workflow
628
+ const workflowTasks = await api.getTasksInWorkflow(dep.blockerId);
629
+ if (workflowTasks.length === 1 && workflowTasks[0].id === id) {
630
+ return c.json({
631
+ error: {
632
+ code: 'LAST_TASK',
633
+ message: "Cannot delete the last task in a workflow. Workflows must have at least one task. Use 'sf workflow delete' to delete the entire workflow."
634
+ }
635
+ }, 400);
636
+ }
637
+ }
638
+ }
639
+ }
640
+ // Soft-delete the task
641
+ await api.delete(id);
642
+ return c.json({ success: true, id });
643
+ }
644
+ catch (error) {
645
+ if (error.code === 'NOT_FOUND') {
646
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
647
+ }
648
+ console.error('[stoneforge] Failed to delete task:', error);
649
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete task' } }, 500);
650
+ }
651
+ });
652
+ // ============================================================================
653
+ // Task Attachments Endpoints
654
+ // ============================================================================
655
+ /**
656
+ * GET /api/tasks/:id/attachments
657
+ * Returns all documents attached to a task via 'references' dependencies
658
+ */
659
+ app.get('/api/tasks/:id/attachments', async (c) => {
660
+ try {
661
+ const taskId = c.req.param('id');
662
+ // Verify task exists
663
+ const task = await api.get(taskId);
664
+ if (!task) {
665
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
666
+ }
667
+ if (task.type !== 'task') {
668
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
669
+ }
670
+ // Get all dependencies where this task references a document
671
+ const dependencies = await api.getDependencies(taskId);
672
+ const attachmentDeps = dependencies.filter((dep) => dep.blockedId === taskId && dep.type === 'references');
673
+ // Get the document details for each attachment
674
+ const attachments = await Promise.all(attachmentDeps.map(async (dep) => {
675
+ const doc = await api.get(dep.blockerId);
676
+ if (doc && doc.type === 'document') {
677
+ return doc;
678
+ }
679
+ return null;
680
+ }));
681
+ // Filter out nulls (in case documents were deleted)
682
+ return c.json(attachments.filter(Boolean));
683
+ }
684
+ catch (error) {
685
+ console.error('[stoneforge] Failed to get task attachments:', error);
686
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get task attachments' } }, 500);
687
+ }
688
+ });
689
+ /**
690
+ * POST /api/tasks/:id/attachments
691
+ * Attaches a document to a task via 'references' dependency
692
+ */
693
+ app.post('/api/tasks/:id/attachments', async (c) => {
694
+ try {
695
+ const taskId = c.req.param('id');
696
+ const body = await c.req.json();
697
+ // Validate document ID
698
+ if (!body.documentId || typeof body.documentId !== 'string') {
699
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'documentId is required' } }, 400);
700
+ }
701
+ // Verify task exists
702
+ const task = await api.get(taskId);
703
+ if (!task) {
704
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
705
+ }
706
+ if (task.type !== 'task') {
707
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
708
+ }
709
+ // Verify document exists
710
+ const doc = await api.get(body.documentId);
711
+ if (!doc) {
712
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
713
+ }
714
+ if (doc.type !== 'document') {
715
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document not found' } }, 404);
716
+ }
717
+ // Check if already attached
718
+ const existingDeps = await api.getDependencies(taskId);
719
+ const alreadyAttached = existingDeps.some((dep) => dep.blockedId === taskId && dep.blockerId === body.documentId && dep.type === 'references');
720
+ if (alreadyAttached) {
721
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Document is already attached to this task' } }, 400);
722
+ }
723
+ // Create the references dependency (task references document)
724
+ await api.addDependency({
725
+ blockedId: taskId,
726
+ blockerId: body.documentId,
727
+ type: 'references',
728
+ actor: body.actor || 'el-0000',
729
+ });
730
+ return c.json(doc, 201);
731
+ }
732
+ catch (error) {
733
+ console.error('[stoneforge] Failed to attach document to task:', error);
734
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to attach document' } }, 500);
735
+ }
736
+ });
737
+ /**
738
+ * DELETE /api/tasks/:id/attachments/:docId
739
+ * Removes a document attachment from a task
740
+ */
741
+ app.delete('/api/tasks/:id/attachments/:docId', async (c) => {
742
+ try {
743
+ const taskId = c.req.param('id');
744
+ const docId = c.req.param('docId');
745
+ // Verify task exists
746
+ const task = await api.get(taskId);
747
+ if (!task) {
748
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
749
+ }
750
+ if (task.type !== 'task') {
751
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
752
+ }
753
+ // Find the attachment dependency
754
+ const dependencies = await api.getDependencies(taskId);
755
+ const attachmentDep = dependencies.find((dep) => dep.blockedId === taskId && dep.blockerId === docId && dep.type === 'references');
756
+ if (!attachmentDep) {
757
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Document is not attached to this task' } }, 404);
758
+ }
759
+ // Remove the dependency
760
+ await api.removeDependency(taskId, docId, 'references');
761
+ return c.json({ success: true, taskId, documentId: docId });
762
+ }
763
+ catch (error) {
764
+ console.error('[stoneforge] Failed to remove task attachment:', error);
765
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to remove attachment' } }, 500);
766
+ }
767
+ });
768
+ /**
769
+ * GET /api/tasks/:id/dependency-tasks
770
+ * Returns hydrated task details for dependencies (blocks/blocked-by)
771
+ * Used for displaying dependencies as sub-issues in TaskDetailPanel (TB84)
772
+ */
773
+ app.get('/api/tasks/:id/dependency-tasks', async (c) => {
774
+ try {
775
+ const taskId = c.req.param('id');
776
+ // Verify task exists
777
+ const task = await api.get(taskId);
778
+ if (!task) {
779
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
780
+ }
781
+ if (task.type !== 'task') {
782
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Task not found' } }, 404);
783
+ }
784
+ // getDependencies(taskId) = rows where taskId is SOURCE (this task blocks others)
785
+ // getDependents(taskId) = rows where taskId is TARGET (other tasks block this task)
786
+ const [outgoingDeps, incomingDeps] = await Promise.all([
787
+ api.getDependencies(taskId), // This task is source -> this task BLOCKS others
788
+ api.getDependents(taskId), // This task is target -> other tasks BLOCK this task
789
+ ]);
790
+ // Filter to only include blocks/awaits dependency types (not references)
791
+ // blockedByDeps: dependencies where THIS task is blocked BY other tasks (incoming)
792
+ const blockedByDeps = incomingDeps.filter(d => d.type === 'blocks' || d.type === 'awaits');
793
+ // blocksDeps: dependencies where THIS task blocks other tasks (outgoing)
794
+ const blocksDeps = outgoingDeps.filter(d => d.type === 'blocks' || d.type === 'awaits');
795
+ // Collect all unique task IDs we need to fetch
796
+ // For blockedBy: this task is the blocked, so fetch the blocker
797
+ // For blocks: this task is the blocker, so fetch the blocked task
798
+ const blockerTaskIds = blockedByDeps.map(d => d.blockerId);
799
+ const blockedTaskIds = blocksDeps.map(d => d.blockedId);
800
+ const allTaskIds = [...new Set([...blockerTaskIds, ...blockedTaskIds])];
801
+ // Fetch all related tasks in parallel
802
+ const tasksMap = new Map();
803
+ if (allTaskIds.length > 0) {
804
+ const taskPromises = allTaskIds.map(async (id) => {
805
+ try {
806
+ const t = await api.get(id);
807
+ if (t && t.type === 'task') {
808
+ return {
809
+ id: t.id,
810
+ title: t.title,
811
+ status: t.status,
812
+ priority: t.priority,
813
+ };
814
+ }
815
+ return null;
816
+ }
817
+ catch {
818
+ return null;
819
+ }
820
+ });
821
+ const tasks = await Promise.all(taskPromises);
822
+ tasks.forEach((t) => {
823
+ if (t)
824
+ tasksMap.set(t.id, t);
825
+ });
826
+ }
827
+ // Build hydrated blocker list (tasks that block this task)
828
+ const blockedBy = blockedByDeps.map((dep) => {
829
+ const blockerTask = tasksMap.get(dep.blockerId);
830
+ return {
831
+ dependencyType: dep.type,
832
+ task: blockerTask || { id: dep.blockerId, title: `Unknown (${dep.blockerId})`, status: 'unknown', priority: 3 },
833
+ };
834
+ });
835
+ // Build hydrated blocking list (tasks blocked by this task)
836
+ const blocks = blocksDeps.map((dep) => {
837
+ const blockedTask = tasksMap.get(dep.blockedId);
838
+ return {
839
+ dependencyType: dep.type,
840
+ task: blockedTask || { id: dep.blockedId, title: `Unknown (${dep.blockedId})`, status: 'unknown', priority: 3 },
841
+ };
842
+ });
843
+ // Calculate progress stats — check terminal statuses across all element types
844
+ // Tasks: closed, tombstone | Plans: completed, cancelled | Workflows: completed, cancelled, failed
845
+ const blockedByResolved = blockedBy.filter(b => ['closed', 'completed', 'tombstone', 'cancelled', 'failed'].includes(b.task.status)).length;
846
+ const blockedByTotal = blockedBy.length;
847
+ return c.json({
848
+ blockedBy,
849
+ blocks,
850
+ progress: {
851
+ resolved: blockedByResolved,
852
+ total: blockedByTotal,
853
+ },
854
+ });
855
+ }
856
+ catch (error) {
857
+ console.error('[stoneforge] Failed to get dependency tasks:', error);
858
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get dependency tasks' } }, 500);
859
+ }
860
+ });
861
+ // ============================================================================
862
+ // Entities Endpoints
863
+ // ============================================================================
864
+ app.get('/api/entities', async (c) => {
865
+ try {
866
+ const url = new URL(c.req.url);
867
+ // Parse pagination and filter parameters
868
+ const limitParam = url.searchParams.get('limit');
869
+ const offsetParam = url.searchParams.get('offset');
870
+ const orderByParam = url.searchParams.get('orderBy');
871
+ const orderDirParam = url.searchParams.get('orderDir');
872
+ const entityTypeParam = url.searchParams.get('entityType');
873
+ const searchParam = url.searchParams.get('search');
874
+ // Build filter
875
+ const filter = {
876
+ type: 'entity',
877
+ };
878
+ if (limitParam) {
879
+ filter.limit = parseInt(limitParam, 10);
880
+ }
881
+ else {
882
+ filter.limit = 50; // Default page size
883
+ }
884
+ if (offsetParam) {
885
+ filter.offset = parseInt(offsetParam, 10);
886
+ }
887
+ if (orderByParam) {
888
+ filter.orderBy = orderByParam;
889
+ }
890
+ else {
891
+ filter.orderBy = 'updated_at';
892
+ }
893
+ if (orderDirParam) {
894
+ filter.orderDir = orderDirParam;
895
+ }
896
+ else {
897
+ filter.orderDir = 'desc';
898
+ }
899
+ // Get paginated results
900
+ const result = await api.listPaginated(filter);
901
+ // Apply client-side filtering for entityType and search (not supported in base filter)
902
+ let filteredItems = result.items;
903
+ if (entityTypeParam && entityTypeParam !== 'all') {
904
+ filteredItems = filteredItems.filter((e) => {
905
+ const entity = e;
906
+ return entity.entityType === entityTypeParam;
907
+ });
908
+ }
909
+ if (searchParam) {
910
+ const query = searchParam.toLowerCase();
911
+ filteredItems = filteredItems.filter((e) => {
912
+ const entity = e;
913
+ return (entity.name.toLowerCase().includes(query) ||
914
+ entity.id.toLowerCase().includes(query) ||
915
+ (entity.tags || []).some((tag) => tag.toLowerCase().includes(query)));
916
+ });
917
+ }
918
+ // Return paginated response format
919
+ return c.json({
920
+ items: filteredItems,
921
+ total: result.total,
922
+ offset: result.offset,
923
+ limit: result.limit,
924
+ hasMore: result.hasMore,
925
+ });
926
+ }
927
+ catch (error) {
928
+ console.error('[stoneforge] Failed to get entities:', error);
929
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entities' } }, 500);
930
+ }
931
+ });
932
+ app.post('/api/entities', async (c) => {
933
+ try {
934
+ const body = await c.req.json();
935
+ const { name, entityType, publicKey, tags, metadata, createdBy } = body;
936
+ // Validation
937
+ if (!name || typeof name !== 'string') {
938
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Name is required' } }, 400);
939
+ }
940
+ if (!entityType || !['agent', 'human', 'system'].includes(entityType)) {
941
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Valid entity type (agent, human, system) is required' } }, 400);
942
+ }
943
+ // Check for duplicate name
944
+ const existingEntities = await api.list({ type: 'entity' });
945
+ const duplicateName = existingEntities.some((e) => {
946
+ const entity = e;
947
+ return entity.name.toLowerCase() === name.toLowerCase();
948
+ });
949
+ if (duplicateName) {
950
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Entity with this name already exists' } }, 400);
951
+ }
952
+ const entityInput = {
953
+ name,
954
+ entityType,
955
+ publicKey,
956
+ tags: tags || [],
957
+ metadata: metadata || {},
958
+ createdBy: (createdBy || 'el-0000'),
959
+ };
960
+ const entity = await createEntity(entityInput);
961
+ const created = await api.create(entity);
962
+ return c.json(created, 201);
963
+ }
964
+ catch (error) {
965
+ console.error('[stoneforge] Failed to create entity:', error);
966
+ const errorMessage = error instanceof Error ? error.message : 'Failed to create entity';
967
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: errorMessage } }, 500);
968
+ }
969
+ });
970
+ app.get('/api/entities/:id', async (c) => {
971
+ try {
972
+ const id = c.req.param('id');
973
+ const entity = await api.get(id);
974
+ if (!entity) {
975
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
976
+ }
977
+ return c.json(entity);
978
+ }
979
+ catch (error) {
980
+ console.error('[stoneforge] Failed to get entity:', error);
981
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity' } }, 500);
982
+ }
983
+ });
984
+ app.patch('/api/entities/:id', async (c) => {
985
+ try {
986
+ const id = c.req.param('id');
987
+ const body = await c.req.json();
988
+ const { name, tags, metadata, active } = body;
989
+ // Verify entity exists
990
+ const existing = await api.get(id);
991
+ if (!existing || existing.type !== 'entity') {
992
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
993
+ }
994
+ // Build updates object
995
+ const updates = {};
996
+ if (name !== undefined) {
997
+ // Validate name format
998
+ if (typeof name !== 'string' || name.trim().length === 0) {
999
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Name must be a non-empty string' } }, 400);
1000
+ }
1001
+ // Check for duplicate name (if changing)
1002
+ const existingEntity = existing;
1003
+ if (name !== existingEntity.name) {
1004
+ const existingEntities = await api.list({ type: 'entity' });
1005
+ const duplicateName = existingEntities.some((e) => {
1006
+ const entity = e;
1007
+ return entity.name.toLowerCase() === name.toLowerCase() && entity.id !== id;
1008
+ });
1009
+ if (duplicateName) {
1010
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Entity with this name already exists' } }, 400);
1011
+ }
1012
+ }
1013
+ updates.name = name.trim();
1014
+ }
1015
+ if (tags !== undefined) {
1016
+ if (!Array.isArray(tags)) {
1017
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Tags must be an array' } }, 400);
1018
+ }
1019
+ updates.tags = tags;
1020
+ }
1021
+ if (metadata !== undefined) {
1022
+ if (typeof metadata !== 'object' || metadata === null) {
1023
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Metadata must be an object' } }, 400);
1024
+ }
1025
+ updates.metadata = metadata;
1026
+ }
1027
+ if (active !== undefined) {
1028
+ if (typeof active !== 'boolean') {
1029
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Active must be a boolean' } }, 400);
1030
+ }
1031
+ updates.active = active;
1032
+ }
1033
+ if (Object.keys(updates).length === 0) {
1034
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'No valid fields to update' } }, 400);
1035
+ }
1036
+ const updated = await api.update(id, updates);
1037
+ return c.json(updated);
1038
+ }
1039
+ catch (error) {
1040
+ console.error('[stoneforge] Failed to update entity:', error);
1041
+ const errorMessage = error instanceof Error ? error.message : 'Failed to update entity';
1042
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: errorMessage } }, 500);
1043
+ }
1044
+ });
1045
+ app.get('/api/entities/:id/tasks', async (c) => {
1046
+ try {
1047
+ const id = c.req.param('id');
1048
+ // Get tasks assigned to this entity
1049
+ const tasks = await api.list({
1050
+ type: 'task',
1051
+ assignee: id,
1052
+ });
1053
+ return c.json(tasks);
1054
+ }
1055
+ catch (error) {
1056
+ console.error('[stoneforge] Failed to get entity tasks:', error);
1057
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity tasks' } }, 500);
1058
+ }
1059
+ });
1060
+ app.get('/api/entities/:id/stats', async (c) => {
1061
+ try {
1062
+ const id = c.req.param('id');
1063
+ // Verify entity exists
1064
+ const entity = await api.get(id);
1065
+ if (!entity || entity.type !== 'entity') {
1066
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1067
+ }
1068
+ // Get tasks assigned to this entity
1069
+ const assignedTasks = await api.list({
1070
+ type: 'task',
1071
+ assignee: id,
1072
+ });
1073
+ // Get tasks created by this entity (filter post-query since createdBy needs EntityId)
1074
+ const allTasks = await api.list({
1075
+ type: 'task',
1076
+ });
1077
+ const createdTasks = allTasks.filter((t) => String(t.createdBy) === String(id));
1078
+ // Get messages sent by this entity
1079
+ const messages = await api.list({
1080
+ type: 'message',
1081
+ });
1082
+ const sentMessages = messages.filter((m) => {
1083
+ const msg = m;
1084
+ return msg.sender === id;
1085
+ });
1086
+ // Get documents created by this entity (filter post-query)
1087
+ const allDocuments = await api.list({
1088
+ type: 'document',
1089
+ });
1090
+ const documents = allDocuments.filter((d) => String(d.createdBy) === String(id));
1091
+ // Calculate task stats
1092
+ const activeTasks = assignedTasks.filter((t) => {
1093
+ const task = t;
1094
+ return task.status !== 'closed' && task.status !== 'cancelled';
1095
+ });
1096
+ const completedTasks = assignedTasks.filter((t) => {
1097
+ const task = t;
1098
+ return task.status === 'closed';
1099
+ });
1100
+ // Calculate tasks completed today
1101
+ const startOfToday = new Date();
1102
+ startOfToday.setHours(0, 0, 0, 0);
1103
+ const completedTodayTasks = completedTasks.filter((t) => {
1104
+ const task = t;
1105
+ return new Date(task.updatedAt) >= startOfToday;
1106
+ });
1107
+ // Calculate blocked tasks
1108
+ const blockedTasks = assignedTasks.filter((t) => {
1109
+ const task = t;
1110
+ return task.status === 'blocked';
1111
+ });
1112
+ // Calculate in-progress tasks
1113
+ const inProgressTasks = assignedTasks.filter((t) => {
1114
+ const task = t;
1115
+ return task.status === 'in_progress';
1116
+ });
1117
+ return c.json({
1118
+ assignedTaskCount: assignedTasks.length,
1119
+ activeTaskCount: activeTasks.length,
1120
+ completedTaskCount: completedTasks.length,
1121
+ completedTodayCount: completedTodayTasks.length,
1122
+ blockedTaskCount: blockedTasks.length,
1123
+ inProgressTaskCount: inProgressTasks.length,
1124
+ createdTaskCount: createdTasks.length,
1125
+ messageCount: sentMessages.length,
1126
+ documentCount: documents.length,
1127
+ });
1128
+ }
1129
+ catch (error) {
1130
+ console.error('[stoneforge] Failed to get entity stats:', error);
1131
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity stats' } }, 500);
1132
+ }
1133
+ });
1134
+ app.get('/api/entities/:id/events', async (c) => {
1135
+ try {
1136
+ const id = c.req.param('id');
1137
+ const url = new URL(c.req.url);
1138
+ const limitParam = url.searchParams.get('limit');
1139
+ const offsetParam = url.searchParams.get('offset');
1140
+ const eventTypeParam = url.searchParams.get('eventType');
1141
+ // Verify entity exists
1142
+ const entity = await api.get(id);
1143
+ if (!entity || entity.type !== 'entity') {
1144
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1145
+ }
1146
+ // Parse event type filter if provided
1147
+ let eventTypeFilter;
1148
+ if (eventTypeParam) {
1149
+ const types = eventTypeParam.split(',').map(t => t.trim()).filter(Boolean);
1150
+ eventTypeFilter = types.length === 1 ? types[0] : types;
1151
+ }
1152
+ // Get events by this actor
1153
+ const events = await api.listEvents({
1154
+ actor: id,
1155
+ limit: limitParam ? parseInt(limitParam, 10) : 20,
1156
+ offset: offsetParam ? parseInt(offsetParam, 10) : undefined,
1157
+ eventType: eventTypeFilter,
1158
+ });
1159
+ return c.json(events);
1160
+ }
1161
+ catch (error) {
1162
+ console.error('[stoneforge] Failed to get entity events:', error);
1163
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity events' } }, 500);
1164
+ }
1165
+ });
1166
+ // GET /api/entities/:id/history - Get entity's full event history with pagination
1167
+ // TB110: Entity Event History (Commit History Style)
1168
+ app.get('/api/entities/:id/history', async (c) => {
1169
+ try {
1170
+ const id = c.req.param('id');
1171
+ const url = new URL(c.req.url);
1172
+ const limitParam = url.searchParams.get('limit');
1173
+ const offsetParam = url.searchParams.get('offset');
1174
+ const eventTypeParam = url.searchParams.get('eventType');
1175
+ // Verify entity exists
1176
+ const entity = await api.get(id);
1177
+ if (!entity || entity.type !== 'entity') {
1178
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1179
+ }
1180
+ const limit = limitParam ? parseInt(limitParam, 10) : 50;
1181
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
1182
+ // Parse event type filter if provided
1183
+ let eventTypeFilter;
1184
+ if (eventTypeParam) {
1185
+ const types = eventTypeParam.split(',').map(t => t.trim()).filter(Boolean);
1186
+ eventTypeFilter = types.length === 1 ? types[0] : types;
1187
+ }
1188
+ // Get total count (events without pagination)
1189
+ const allEvents = await api.listEvents({
1190
+ actor: id,
1191
+ limit: 100000, // High limit to get total count
1192
+ eventType: eventTypeFilter,
1193
+ });
1194
+ const total = allEvents.length;
1195
+ // Get paginated events
1196
+ const events = await api.listEvents({
1197
+ actor: id,
1198
+ limit,
1199
+ offset,
1200
+ eventType: eventTypeFilter,
1201
+ });
1202
+ return c.json({
1203
+ items: events,
1204
+ total,
1205
+ offset,
1206
+ limit,
1207
+ hasMore: offset + events.length < total,
1208
+ });
1209
+ }
1210
+ catch (error) {
1211
+ console.error('[stoneforge] Failed to get entity history:', error);
1212
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity history' } }, 500);
1213
+ }
1214
+ });
1215
+ // GET /api/entities/:id/activity - Get daily activity counts for contribution chart
1216
+ // TB108: Entity Contribution Chart - GitHub-style activity grid
1217
+ app.get('/api/entities/:id/activity', async (c) => {
1218
+ try {
1219
+ const id = c.req.param('id');
1220
+ const url = new URL(c.req.url);
1221
+ const daysParam = url.searchParams.get('days');
1222
+ const days = daysParam ? parseInt(daysParam, 10) : 365;
1223
+ // Verify entity exists
1224
+ const entity = await api.get(id);
1225
+ if (!entity || entity.type !== 'entity') {
1226
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1227
+ }
1228
+ // Calculate date range
1229
+ const endDate = new Date();
1230
+ const startDate = new Date();
1231
+ startDate.setDate(startDate.getDate() - days);
1232
+ // Get all events by this actor in the date range
1233
+ const events = await api.listEvents({
1234
+ actor: id,
1235
+ after: startDate.toISOString(),
1236
+ before: endDate.toISOString(),
1237
+ limit: 10000, // Get all events in range
1238
+ });
1239
+ // Aggregate by date (YYYY-MM-DD)
1240
+ const activityByDate = {};
1241
+ for (const event of events) {
1242
+ const date = event.createdAt.split('T')[0]; // Extract YYYY-MM-DD
1243
+ activityByDate[date] = (activityByDate[date] || 0) + 1;
1244
+ }
1245
+ // Convert to array format for frontend
1246
+ const activity = Object.entries(activityByDate).map(([date, count]) => ({
1247
+ date,
1248
+ count,
1249
+ }));
1250
+ // Sort by date ascending
1251
+ activity.sort((a, b) => a.date.localeCompare(b.date));
1252
+ return c.json({
1253
+ entityId: id,
1254
+ startDate: startDate.toISOString().split('T')[0],
1255
+ endDate: endDate.toISOString().split('T')[0],
1256
+ totalEvents: events.length,
1257
+ activity,
1258
+ });
1259
+ }
1260
+ catch (error) {
1261
+ console.error('[stoneforge] Failed to get entity activity:', error);
1262
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity activity' } }, 500);
1263
+ }
1264
+ });
1265
+ // GET /api/entities/:id/mentions - Get documents and tasks that mention this entity
1266
+ // TB113: Entity Tags Display - Shows where this entity is @mentioned
1267
+ app.get('/api/entities/:id/mentions', async (c) => {
1268
+ try {
1269
+ const id = c.req.param('id');
1270
+ const url = new URL(c.req.url);
1271
+ const limitParam = url.searchParams.get('limit');
1272
+ const limit = limitParam ? parseInt(limitParam, 10) : 50;
1273
+ // Verify entity exists and get their name
1274
+ const entity = await api.get(id);
1275
+ if (!entity || entity.type !== 'entity') {
1276
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1277
+ }
1278
+ const entityTyped = entity;
1279
+ const entityName = entityTyped.name;
1280
+ // Create search pattern for @mentions (stored as @name in Markdown)
1281
+ const mentionPattern = `@${entityName}`;
1282
+ // Search for documents containing the mention
1283
+ const allDocuments = await api.list({
1284
+ type: 'document',
1285
+ });
1286
+ const mentioningDocuments = [];
1287
+ for (const doc of allDocuments) {
1288
+ const docTyped = doc;
1289
+ const content = docTyped.content || '';
1290
+ // Check if content contains the @mention
1291
+ if (content.includes(mentionPattern)) {
1292
+ mentioningDocuments.push({
1293
+ id: docTyped.id,
1294
+ title: docTyped.title || `Document ${docTyped.id}`,
1295
+ contentType: docTyped.contentType,
1296
+ updatedAt: docTyped.updatedAt,
1297
+ type: 'document',
1298
+ });
1299
+ if (mentioningDocuments.length >= limit)
1300
+ break;
1301
+ }
1302
+ }
1303
+ // Sort documents by updatedAt (most recent first)
1304
+ const allMentions = mentioningDocuments
1305
+ .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
1306
+ .slice(0, limit);
1307
+ return c.json({
1308
+ entityId: id,
1309
+ entityName,
1310
+ mentions: allMentions,
1311
+ documentCount: mentioningDocuments.length,
1312
+ totalCount: mentioningDocuments.length,
1313
+ });
1314
+ }
1315
+ catch (error) {
1316
+ console.error('[stoneforge] Failed to get entity mentions:', error);
1317
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity mentions' } }, 500);
1318
+ }
1319
+ });
1320
+ // ============================================================================
1321
+ // Inbox Endpoints
1322
+ // ============================================================================
1323
+ // GET /api/entities/:id/inbox - Get entity's inbox with pagination and optional hydration
1324
+ app.get('/api/entities/:id/inbox', async (c) => {
1325
+ try {
1326
+ const id = c.req.param('id');
1327
+ const url = new URL(c.req.url);
1328
+ // Parse pagination params
1329
+ const limitParam = url.searchParams.get('limit');
1330
+ const offsetParam = url.searchParams.get('offset');
1331
+ const statusParam = url.searchParams.get('status');
1332
+ const sourceTypeParam = url.searchParams.get('sourceType');
1333
+ const hydrateParam = url.searchParams.get('hydrate');
1334
+ // Verify entity exists
1335
+ const entity = await api.get(id);
1336
+ if (!entity || entity.type !== 'entity') {
1337
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1338
+ }
1339
+ // Build filter
1340
+ const filter = {
1341
+ limit: limitParam ? parseInt(limitParam, 10) : 25,
1342
+ offset: offsetParam ? parseInt(offsetParam, 10) : 0,
1343
+ };
1344
+ // Handle status filter (can be comma-separated for multiple values)
1345
+ if (statusParam) {
1346
+ const statuses = statusParam.split(',');
1347
+ filter.status = statuses.length === 1 ? statuses[0] : statuses;
1348
+ }
1349
+ // Handle source type filter
1350
+ if (sourceTypeParam) {
1351
+ filter.sourceType = sourceTypeParam;
1352
+ }
1353
+ // Get paginated inbox items
1354
+ const result = inboxService.getInboxPaginated(id, filter);
1355
+ // Hydrate items if requested
1356
+ let items = result.items;
1357
+ if (hydrateParam === 'true') {
1358
+ // Hydrate each inbox item with message, channel, sender, and TB92 enhancements
1359
+ items = await Promise.all(result.items.map(async (item) => {
1360
+ try {
1361
+ // Get message
1362
+ const message = await api.get(item.messageId);
1363
+ // Get channel
1364
+ const channel = await api.get(item.channelId);
1365
+ // Get sender from message
1366
+ let sender = null;
1367
+ if (message?.sender) {
1368
+ sender = await api.get(message.sender);
1369
+ }
1370
+ // Get message content - both preview and full content (TB92)
1371
+ let messagePreview = '';
1372
+ let fullContent = '';
1373
+ let contentType = 'text';
1374
+ if (message?.contentRef) {
1375
+ const contentDoc = await api.get(message.contentRef);
1376
+ if (contentDoc?.content) {
1377
+ fullContent = contentDoc.content;
1378
+ contentType = contentDoc.contentType ?? 'text';
1379
+ // Truncate content for preview
1380
+ messagePreview = contentDoc.content.substring(0, 150);
1381
+ if (contentDoc.content.length > 150) {
1382
+ messagePreview += '...';
1383
+ }
1384
+ }
1385
+ }
1386
+ // TB92: Hydrate attachments (document embeds)
1387
+ let hydratedAttachments = [];
1388
+ if (message?.attachments && message.attachments.length > 0) {
1389
+ hydratedAttachments = await Promise.all(message.attachments.map(async (attachmentId) => {
1390
+ try {
1391
+ const attachmentDoc = await api.get(attachmentId);
1392
+ if (attachmentDoc) {
1393
+ // Derive title from first line of content or use ID
1394
+ const firstLine = attachmentDoc.content?.split('\n')[0]?.substring(0, 50) ?? '';
1395
+ const title = firstLine.replace(/^#+\s*/, '') || `Document ${attachmentDoc.id}`;
1396
+ return {
1397
+ id: attachmentDoc.id,
1398
+ title: title,
1399
+ content: attachmentDoc.content,
1400
+ contentType: attachmentDoc.contentType ?? 'text',
1401
+ };
1402
+ }
1403
+ return { id: attachmentId, title: 'Unknown Document' };
1404
+ }
1405
+ catch {
1406
+ return { id: attachmentId, title: 'Unknown Document' };
1407
+ }
1408
+ }));
1409
+ }
1410
+ // TB92: Get thread parent message if this is a reply
1411
+ let threadParent = null;
1412
+ if (message?.threadId) {
1413
+ try {
1414
+ const parentMessage = await api.get(message.threadId);
1415
+ if (parentMessage) {
1416
+ // Get parent sender
1417
+ let parentSender = null;
1418
+ if (parentMessage.sender) {
1419
+ parentSender = await api.get(parentMessage.sender);
1420
+ }
1421
+ // Get parent content preview
1422
+ let parentPreview = '';
1423
+ if (parentMessage.contentRef) {
1424
+ const parentContentDoc = await api.get(parentMessage.contentRef);
1425
+ if (parentContentDoc?.content) {
1426
+ parentPreview = parentContentDoc.content.substring(0, 100);
1427
+ if (parentContentDoc.content.length > 100) {
1428
+ parentPreview += '...';
1429
+ }
1430
+ }
1431
+ }
1432
+ threadParent = {
1433
+ id: parentMessage.id,
1434
+ sender: parentSender,
1435
+ contentPreview: parentPreview,
1436
+ createdAt: parentMessage.createdAt,
1437
+ };
1438
+ }
1439
+ }
1440
+ catch {
1441
+ // Thread parent fetch failed, continue without it
1442
+ }
1443
+ }
1444
+ return {
1445
+ ...item,
1446
+ message: message ? {
1447
+ ...message,
1448
+ contentPreview: messagePreview,
1449
+ fullContent: fullContent,
1450
+ contentType: contentType,
1451
+ } : null,
1452
+ channel: channel,
1453
+ sender: sender,
1454
+ attachments: hydratedAttachments,
1455
+ threadParent: threadParent,
1456
+ };
1457
+ }
1458
+ catch (err) {
1459
+ // If hydration fails for an item, return it without hydration
1460
+ console.warn(`[stoneforge] Failed to hydrate inbox item ${item.id}:`, err);
1461
+ return item;
1462
+ }
1463
+ }));
1464
+ }
1465
+ return c.json({
1466
+ items,
1467
+ total: result.total,
1468
+ offset: filter.offset ?? 0,
1469
+ limit: filter.limit ?? 25,
1470
+ hasMore: (filter.offset ?? 0) + result.items.length < result.total,
1471
+ });
1472
+ }
1473
+ catch (error) {
1474
+ console.error('[stoneforge] Failed to get entity inbox:', error);
1475
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity inbox' } }, 500);
1476
+ }
1477
+ });
1478
+ // GET /api/entities/:id/inbox/count - Get unread inbox count
1479
+ app.get('/api/entities/:id/inbox/count', async (c) => {
1480
+ try {
1481
+ const id = c.req.param('id');
1482
+ // Verify entity exists
1483
+ const entity = await api.get(id);
1484
+ if (!entity || entity.type !== 'entity') {
1485
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1486
+ }
1487
+ const count = inboxService.getUnreadCount(id);
1488
+ return c.json({ count });
1489
+ }
1490
+ catch (error) {
1491
+ console.error('[stoneforge] Failed to get inbox count:', error);
1492
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get inbox count' } }, 500);
1493
+ }
1494
+ });
1495
+ // POST /api/entities/:id/inbox/mark-all-read - Mark all inbox items as read
1496
+ app.post('/api/entities/:id/inbox/mark-all-read', async (c) => {
1497
+ try {
1498
+ const id = c.req.param('id');
1499
+ // Verify entity exists
1500
+ const entity = await api.get(id);
1501
+ if (!entity || entity.type !== 'entity') {
1502
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1503
+ }
1504
+ const count = inboxService.markAllAsRead(id);
1505
+ // Broadcast bulk update event for real-time updates
1506
+ // Since this is a bulk operation, broadcast a single event with count info
1507
+ if (count > 0) {
1508
+ broadcastInboxEvent(`bulk-${id}`, // Pseudo ID for bulk operation
1509
+ id, 'updated', null, { bulkMarkRead: true, count });
1510
+ }
1511
+ return c.json({ markedCount: count });
1512
+ }
1513
+ catch (error) {
1514
+ console.error('[stoneforge] Failed to mark all as read:', error);
1515
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to mark all as read' } }, 500);
1516
+ }
1517
+ });
1518
+ // PATCH /api/inbox/:itemId - Update inbox item status
1519
+ app.patch('/api/inbox/:itemId', async (c) => {
1520
+ try {
1521
+ const itemId = c.req.param('itemId');
1522
+ const body = await c.req.json();
1523
+ if (!body.status) {
1524
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'status is required' } }, 400);
1525
+ }
1526
+ // Get old item state for event broadcasting
1527
+ const oldItem = inboxService.getInboxItem(itemId);
1528
+ if (!oldItem) {
1529
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Inbox item not found' } }, 404);
1530
+ }
1531
+ let item;
1532
+ switch (body.status) {
1533
+ case 'read':
1534
+ item = inboxService.markAsRead(itemId);
1535
+ break;
1536
+ case 'unread':
1537
+ item = inboxService.markAsUnread(itemId);
1538
+ break;
1539
+ case 'archived':
1540
+ item = inboxService.archive(itemId);
1541
+ break;
1542
+ default:
1543
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid status. Must be read, unread, or archived' } }, 400);
1544
+ }
1545
+ // Broadcast inbox event for real-time updates
1546
+ broadcastInboxEvent(itemId, item.recipientId, 'updated', { status: oldItem.status, readAt: oldItem.readAt }, { status: item.status, readAt: item.readAt });
1547
+ return c.json(item);
1548
+ }
1549
+ catch (error) {
1550
+ const errorObj = error;
1551
+ if (errorObj.code === 'NOT_FOUND') {
1552
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Inbox item not found' } }, 404);
1553
+ }
1554
+ console.error('[stoneforge] Failed to update inbox item:', error);
1555
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to update inbox item' } }, 500);
1556
+ }
1557
+ });
1558
+ // GET /api/inbox/all - Global inbox view across all entities (TB89)
1559
+ // NOTE: This route MUST be defined before /api/inbox/:itemId to prevent "all" being matched as itemId
1560
+ // Supports filtering by entityId to show a specific user's inbox
1561
+ app.get('/api/inbox/all', async (c) => {
1562
+ try {
1563
+ const url = new URL(c.req.url);
1564
+ // Parse query parameters
1565
+ const limitParam = url.searchParams.get('limit');
1566
+ const offsetParam = url.searchParams.get('offset');
1567
+ const statusParam = url.searchParams.get('status');
1568
+ const hydrateParam = url.searchParams.get('hydrate');
1569
+ const entityIdParam = url.searchParams.get('entityId');
1570
+ const limit = limitParam ? parseInt(limitParam, 10) : 50;
1571
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
1572
+ // Build filter for status
1573
+ const filter = {
1574
+ limit,
1575
+ offset,
1576
+ };
1577
+ if (statusParam) {
1578
+ filter.status = statusParam;
1579
+ }
1580
+ // Get inbox items - optionally filtered by entityId
1581
+ // This requires a raw query since InboxService only supports per-entity queries
1582
+ // Handle comma-separated statuses (e.g., "unread,read")
1583
+ let statusCondition = '';
1584
+ if (statusParam) {
1585
+ const statuses = statusParam.split(',').map(s => s.trim());
1586
+ if (statuses.length === 1) {
1587
+ statusCondition = `AND status = '${statuses[0]}'`;
1588
+ }
1589
+ else {
1590
+ statusCondition = `AND status IN (${statuses.map(s => `'${s}'`).join(', ')})`;
1591
+ }
1592
+ }
1593
+ const entityCondition = entityIdParam ? `AND recipient_id = '${entityIdParam}'` : '';
1594
+ const countResult = storageBackend.queryOne(`SELECT COUNT(*) as count FROM inbox_items WHERE 1=1 ${statusCondition} ${entityCondition}`, []);
1595
+ const total = countResult?.count ?? 0;
1596
+ const rows = storageBackend.query(`SELECT id, recipient_id, message_id, channel_id, source_type, status, read_at, created_at
1597
+ FROM inbox_items
1598
+ WHERE 1=1 ${statusCondition} ${entityCondition}
1599
+ ORDER BY created_at DESC
1600
+ LIMIT ? OFFSET ?`, [limit, offset]);
1601
+ // Map rows to inbox items
1602
+ let items = rows.map(row => ({
1603
+ id: row.id,
1604
+ recipientId: row.recipient_id,
1605
+ messageId: row.message_id,
1606
+ channelId: row.channel_id,
1607
+ sourceType: row.source_type,
1608
+ status: row.status,
1609
+ readAt: row.read_at,
1610
+ createdAt: row.created_at,
1611
+ }));
1612
+ // Hydrate items if requested
1613
+ if (hydrateParam === 'true') {
1614
+ items = await Promise.all(items.map(async (item) => {
1615
+ const hydratedItem = { ...item };
1616
+ // Hydrate message
1617
+ try {
1618
+ const message = await api.get(item.messageId);
1619
+ if (message && message.type === 'message') {
1620
+ const typedMessage = message;
1621
+ // Get content preview
1622
+ let contentPreview = '';
1623
+ if (typedMessage.contentRef) {
1624
+ const contentDoc = await api.get(typedMessage.contentRef);
1625
+ if (contentDoc && contentDoc.type === 'document') {
1626
+ const typedDoc = contentDoc;
1627
+ contentPreview = typeof typedDoc.content === 'string'
1628
+ ? typedDoc.content.substring(0, 100)
1629
+ : '';
1630
+ }
1631
+ }
1632
+ hydratedItem.message = {
1633
+ id: message.id,
1634
+ sender: typedMessage.sender,
1635
+ contentRef: typedMessage.contentRef,
1636
+ contentPreview,
1637
+ createdAt: message.createdAt,
1638
+ };
1639
+ }
1640
+ }
1641
+ catch {
1642
+ // Message might be deleted
1643
+ }
1644
+ // Hydrate channel
1645
+ try {
1646
+ const channel = await api.get(item.channelId);
1647
+ if (channel && channel.type === 'channel') {
1648
+ const typedChannel = channel;
1649
+ hydratedItem.channel = {
1650
+ id: channel.id,
1651
+ name: typedChannel.name,
1652
+ channelType: typedChannel.channelType,
1653
+ };
1654
+ }
1655
+ }
1656
+ catch {
1657
+ // Channel might be deleted
1658
+ }
1659
+ // Hydrate recipient entity
1660
+ try {
1661
+ const recipient = await api.get(item.recipientId);
1662
+ if (recipient && recipient.type === 'entity') {
1663
+ hydratedItem.recipient = recipient;
1664
+ }
1665
+ }
1666
+ catch {
1667
+ // Recipient might be deleted
1668
+ }
1669
+ // Hydrate sender entity (from message)
1670
+ if (hydratedItem.message && hydratedItem.message.sender) {
1671
+ try {
1672
+ const sender = await api.get(hydratedItem.message.sender);
1673
+ if (sender && sender.type === 'entity') {
1674
+ hydratedItem.sender = sender;
1675
+ }
1676
+ }
1677
+ catch {
1678
+ // Sender might be deleted
1679
+ }
1680
+ }
1681
+ return hydratedItem;
1682
+ }));
1683
+ }
1684
+ return c.json({
1685
+ items,
1686
+ total,
1687
+ offset,
1688
+ limit,
1689
+ hasMore: offset + items.length < total,
1690
+ });
1691
+ }
1692
+ catch (error) {
1693
+ console.error('[stoneforge] Failed to get global inbox:', error);
1694
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get global inbox' } }, 500);
1695
+ }
1696
+ });
1697
+ // GET /api/inbox/count - Global inbox unread count (TB137)
1698
+ // NOTE: This route MUST be defined before /api/inbox/:itemId to prevent "count" being matched as itemId
1699
+ // Supports filtering by entityId to get count for a specific user
1700
+ app.get('/api/inbox/count', async (c) => {
1701
+ try {
1702
+ const url = new URL(c.req.url);
1703
+ const statusParam = url.searchParams.get('status');
1704
+ const entityIdParam = url.searchParams.get('entityId');
1705
+ // Build WHERE conditions
1706
+ const conditions = [];
1707
+ if (statusParam) {
1708
+ conditions.push(`status = '${statusParam}'`);
1709
+ }
1710
+ else {
1711
+ conditions.push(`status = 'unread'`);
1712
+ }
1713
+ if (entityIdParam) {
1714
+ conditions.push(`recipient_id = '${entityIdParam}'`);
1715
+ }
1716
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
1717
+ const countResult = storageBackend.queryOne(`SELECT COUNT(*) as count FROM inbox_items ${whereClause}`, []);
1718
+ return c.json({ count: countResult?.count ?? 0 });
1719
+ }
1720
+ catch (error) {
1721
+ console.error('[stoneforge] Failed to get global inbox count:', error);
1722
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get global inbox count' } }, 500);
1723
+ }
1724
+ });
1725
+ // GET /api/inbox/:itemId - Get single inbox item
1726
+ app.get('/api/inbox/:itemId', async (c) => {
1727
+ try {
1728
+ const itemId = c.req.param('itemId');
1729
+ const item = inboxService.getInboxItem(itemId);
1730
+ if (!item) {
1731
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Inbox item not found' } }, 404);
1732
+ }
1733
+ return c.json(item);
1734
+ }
1735
+ catch (error) {
1736
+ console.error('[stoneforge] Failed to get inbox item:', error);
1737
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get inbox item' } }, 500);
1738
+ }
1739
+ });
1740
+ // ============================================================================
1741
+ // Entity Hierarchy Endpoints
1742
+ // ============================================================================
1743
+ // GET /api/entities/:id/reports - Get direct reports for an entity
1744
+ app.get('/api/entities/:id/reports', async (c) => {
1745
+ try {
1746
+ const id = c.req.param('id');
1747
+ // Verify entity exists
1748
+ const entity = await api.get(id);
1749
+ if (!entity || entity.type !== 'entity') {
1750
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1751
+ }
1752
+ // Get all entities and filter for direct reports
1753
+ const allEntities = await api.list({ type: 'entity' });
1754
+ const reports = getDirectReports(allEntities, id);
1755
+ return c.json(reports);
1756
+ }
1757
+ catch (error) {
1758
+ console.error('[stoneforge] Failed to get entity reports:', error);
1759
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get entity reports' } }, 500);
1760
+ }
1761
+ });
1762
+ // GET /api/entities/:id/chain - Get management chain for an entity
1763
+ app.get('/api/entities/:id/chain', async (c) => {
1764
+ try {
1765
+ const id = c.req.param('id');
1766
+ // Verify entity exists
1767
+ const entity = await api.get(id);
1768
+ if (!entity || entity.type !== 'entity') {
1769
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1770
+ }
1771
+ // Load all entities for chain lookup
1772
+ const allEntities = await api.list({ type: 'entity' });
1773
+ // Create a sync getEntity function for chain lookup
1774
+ const getEntityById = (entityId) => {
1775
+ return allEntities.find(e => e.id === entityId) || null;
1776
+ };
1777
+ // Get the management chain
1778
+ const chain = getManagementChain(entity, getEntityById);
1779
+ return c.json(chain);
1780
+ }
1781
+ catch (error) {
1782
+ console.error('[stoneforge] Failed to get management chain:', error);
1783
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get management chain' } }, 500);
1784
+ }
1785
+ });
1786
+ // PATCH /api/entities/:id/manager - Set or clear manager for an entity
1787
+ app.patch('/api/entities/:id/manager', async (c) => {
1788
+ try {
1789
+ const id = c.req.param('id');
1790
+ const body = await c.req.json();
1791
+ // Verify entity exists
1792
+ const entity = await api.get(id);
1793
+ if (!entity || entity.type !== 'entity') {
1794
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Entity not found' } }, 404);
1795
+ }
1796
+ // If setting a manager (not clearing)
1797
+ if (body.managerId !== null) {
1798
+ // Verify manager exists
1799
+ const manager = await api.get(body.managerId);
1800
+ if (!manager || manager.type !== 'entity') {
1801
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Manager entity not found' } }, 404);
1802
+ }
1803
+ // Check for self-assignment
1804
+ if (body.managerId === id) {
1805
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Entity cannot be its own manager' } }, 400);
1806
+ }
1807
+ // Check for cycles using detectReportingCycle
1808
+ const allEntities = await api.list({ type: 'entity' });
1809
+ // Create a getEntity function for cycle detection
1810
+ const getEntityForCycle = (entityId) => {
1811
+ return allEntities.find(e => e.id === entityId) || null;
1812
+ };
1813
+ // Check if setting this manager would create a cycle
1814
+ const cycleResult = detectReportingCycle(id, body.managerId, getEntityForCycle);
1815
+ if (cycleResult.hasCycle) {
1816
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Setting this manager would create a reporting cycle' } }, 400);
1817
+ }
1818
+ }
1819
+ // Update the entity with new reportsTo value
1820
+ const updates = {
1821
+ reportsTo: body.managerId,
1822
+ };
1823
+ const updated = await api.update(id, updates);
1824
+ return c.json(updated);
1825
+ }
1826
+ catch (error) {
1827
+ console.error('[stoneforge] Failed to set entity manager:', error);
1828
+ const errorMessage = error instanceof Error ? error.message : 'Failed to set entity manager';
1829
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: errorMessage } }, 500);
1830
+ }
1831
+ });
1832
+ // ============================================================================
1833
+ // Dependencies Endpoints
1834
+ // ============================================================================
1835
+ app.get('/api/dependencies/:id/tree', async (c) => {
1836
+ try {
1837
+ const id = c.req.param('id');
1838
+ const tree = await api.getDependencyTree(id);
1839
+ return c.json(tree);
1840
+ }
1841
+ catch (error) {
1842
+ if (error.code === 'NOT_FOUND') {
1843
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Element not found' } }, 404);
1844
+ }
1845
+ console.error('[stoneforge] Failed to get dependency tree:', error);
1846
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get dependency tree' } }, 500);
1847
+ }
1848
+ });
1849
+ app.get('/api/dependencies/:id', async (c) => {
1850
+ try {
1851
+ const id = c.req.param('id');
1852
+ const dependencies = await api.getDependencies(id);
1853
+ const dependents = await api.getDependents(id);
1854
+ return c.json({ dependencies, dependents });
1855
+ }
1856
+ catch (error) {
1857
+ console.error('[stoneforge] Failed to get dependencies:', error);
1858
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get dependencies' } }, 500);
1859
+ }
1860
+ });
1861
+ // POST /api/dependencies - Create a dependency
1862
+ app.post('/api/dependencies', async (c) => {
1863
+ try {
1864
+ const body = await c.req.json();
1865
+ // Validate required fields
1866
+ if (!body.blockedId || !body.blockerId || !body.type) {
1867
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'blockedId, blockerId, and type are required' } }, 400);
1868
+ }
1869
+ const dependency = await api.addDependency({
1870
+ blockedId: body.blockedId,
1871
+ blockerId: body.blockerId,
1872
+ type: body.type,
1873
+ metadata: body.metadata,
1874
+ actor: body.actor,
1875
+ });
1876
+ // Events are automatically recorded in the database by addDependency
1877
+ // and will be picked up by the event broadcaster's polling mechanism
1878
+ return c.json(dependency, 201);
1879
+ }
1880
+ catch (error) {
1881
+ const errorObj = error;
1882
+ // Handle cycle detection
1883
+ if (errorObj.code === 'CYCLE_DETECTED') {
1884
+ return c.json({ error: { code: 'CYCLE_DETECTED', message: errorObj.message || 'Adding this dependency would create a cycle' } }, 400);
1885
+ }
1886
+ // Handle duplicate dependency
1887
+ if (errorObj.code === 'DUPLICATE_DEPENDENCY' || errorObj.name === 'ConflictError') {
1888
+ return c.json({ error: { code: 'CONFLICT', message: errorObj.message || 'Dependency already exists' } }, 409);
1889
+ }
1890
+ // Handle not found
1891
+ if (errorObj.code === 'NOT_FOUND' || errorObj.name === 'NotFoundError') {
1892
+ return c.json({ error: { code: 'NOT_FOUND', message: errorObj.message || 'Source or target element not found' } }, 404);
1893
+ }
1894
+ // Handle validation errors
1895
+ if (errorObj.code === 'VALIDATION_ERROR' || errorObj.code === 'INVALID_DEPENDENCY_TYPE' || errorObj.name === 'ValidationError') {
1896
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: errorObj.message || 'Invalid dependency type' } }, 400);
1897
+ }
1898
+ console.error('[stoneforge] Failed to create dependency:', error);
1899
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to create dependency' } }, 500);
1900
+ }
1901
+ });
1902
+ // DELETE /api/dependencies/:blockedId/:blockerId/:type - Remove a dependency
1903
+ app.delete('/api/dependencies/:blockedId/:blockerId/:type', async (c) => {
1904
+ try {
1905
+ const blockedId = c.req.param('blockedId');
1906
+ const blockerId = c.req.param('blockerId');
1907
+ const type = c.req.param('type');
1908
+ const actor = c.req.query('actor');
1909
+ await api.removeDependency(blockedId, blockerId, type, actor);
1910
+ // Events are automatically recorded in the database by removeDependency
1911
+ // and will be picked up by the event broadcaster's polling mechanism
1912
+ return c.json({ success: true, message: 'Dependency removed' });
1913
+ }
1914
+ catch (error) {
1915
+ const errorObj = error;
1916
+ if (errorObj.code === 'NOT_FOUND' || errorObj.name === 'NotFoundError') {
1917
+ return c.json({ error: { code: 'NOT_FOUND', message: errorObj.message || 'Dependency not found' } }, 404);
1918
+ }
1919
+ console.error('[stoneforge] Failed to remove dependency:', error);
1920
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to remove dependency' } }, 500);
1921
+ }
1922
+ });
1923
+ // ============================================================================
1924
+ // Events Endpoints
1925
+ // ============================================================================
1926
+ app.get('/api/events', async (c) => {
1927
+ try {
1928
+ // Parse query parameters for filtering
1929
+ const url = new URL(c.req.url);
1930
+ const eventType = url.searchParams.get('eventType');
1931
+ const actor = url.searchParams.get('actor');
1932
+ const elementId = url.searchParams.get('elementId');
1933
+ const after = url.searchParams.get('after');
1934
+ const before = url.searchParams.get('before');
1935
+ const limitParam = url.searchParams.get('limit');
1936
+ const offsetParam = url.searchParams.get('offset');
1937
+ const paginatedParam = url.searchParams.get('paginated');
1938
+ // Build filter object - cast to EventFilter type
1939
+ const filter = {};
1940
+ if (eventType) {
1941
+ // Support comma-separated event types
1942
+ filter.eventType = eventType.includes(',') ? eventType.split(',') : eventType;
1943
+ }
1944
+ if (actor) {
1945
+ filter.actor = actor;
1946
+ }
1947
+ if (elementId) {
1948
+ filter.elementId = elementId;
1949
+ }
1950
+ if (after) {
1951
+ filter.after = after;
1952
+ }
1953
+ if (before) {
1954
+ filter.before = before;
1955
+ }
1956
+ const limit = limitParam ? parseInt(limitParam, 10) : 100;
1957
+ const offset = offsetParam ? parseInt(offsetParam, 10) : 0;
1958
+ filter.limit = limit;
1959
+ filter.offset = offset;
1960
+ const events = await api.listEvents(filter);
1961
+ // If paginated=true, return paginated response format with accurate total count
1962
+ if (paginatedParam === 'true') {
1963
+ // Get accurate total count (excluding limit/offset for count query)
1964
+ const countFilter = { ...filter };
1965
+ delete countFilter.limit;
1966
+ delete countFilter.offset;
1967
+ const total = await api.countEvents(countFilter);
1968
+ const hasMore = offset + events.length < total;
1969
+ return c.json({
1970
+ items: events,
1971
+ total: total,
1972
+ offset: offset,
1973
+ limit: limit,
1974
+ hasMore: hasMore,
1975
+ });
1976
+ }
1977
+ return c.json(events);
1978
+ }
1979
+ catch (error) {
1980
+ console.error('[stoneforge] Failed to get events:', error);
1981
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get events' } }, 500);
1982
+ }
1983
+ });
1984
+ // Get count of events matching filter (for eager loading pagination)
1985
+ app.get('/api/events/count', async (c) => {
1986
+ try {
1987
+ const url = new URL(c.req.url);
1988
+ const eventType = url.searchParams.get('eventType');
1989
+ const actor = url.searchParams.get('actor');
1990
+ const elementId = url.searchParams.get('elementId');
1991
+ const after = url.searchParams.get('after');
1992
+ const before = url.searchParams.get('before');
1993
+ const filter = {};
1994
+ if (eventType) {
1995
+ filter.eventType = eventType.includes(',') ? eventType.split(',') : eventType;
1996
+ }
1997
+ if (actor) {
1998
+ filter.actor = actor;
1999
+ }
2000
+ if (elementId) {
2001
+ filter.elementId = elementId;
2002
+ }
2003
+ if (after) {
2004
+ filter.after = after;
2005
+ }
2006
+ if (before) {
2007
+ filter.before = before;
2008
+ }
2009
+ const count = await api.countEvents(filter);
2010
+ return c.json({ count });
2011
+ }
2012
+ catch (error) {
2013
+ console.error('[stoneforge] Failed to count events:', error);
2014
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to count events' } }, 500);
2015
+ }
2016
+ });
2017
+ // ============================================================================
2018
+ // Workflows Endpoints (TB25)
2019
+ // ============================================================================
2020
+ app.get('/api/workflows', async (c) => {
2021
+ try {
2022
+ const url = new URL(c.req.url);
2023
+ const statusParam = url.searchParams.get('status');
2024
+ const ephemeralParam = url.searchParams.get('ephemeral');
2025
+ const limitParam = url.searchParams.get('limit');
2026
+ const offsetParam = url.searchParams.get('offset');
2027
+ const filter = {
2028
+ type: 'workflow',
2029
+ orderBy: 'updated_at',
2030
+ orderDir: 'desc',
2031
+ };
2032
+ if (statusParam) {
2033
+ filter.status = statusParam;
2034
+ }
2035
+ if (ephemeralParam !== null) {
2036
+ filter.ephemeral = ephemeralParam === 'true';
2037
+ }
2038
+ if (limitParam) {
2039
+ filter.limit = parseInt(limitParam, 10);
2040
+ }
2041
+ if (offsetParam) {
2042
+ filter.offset = parseInt(offsetParam, 10);
2043
+ }
2044
+ const workflows = await api.list(filter);
2045
+ return c.json(workflows);
2046
+ }
2047
+ catch (error) {
2048
+ console.error('[stoneforge] Failed to get workflows:', error);
2049
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get workflows' } }, 500);
2050
+ }
2051
+ });
2052
+ app.get('/api/workflows/:id', async (c) => {
2053
+ try {
2054
+ const id = c.req.param('id');
2055
+ const url = new URL(c.req.url);
2056
+ const hydrateProgress = url.searchParams.get('hydrate.progress') === 'true';
2057
+ const workflow = await api.get(id);
2058
+ if (!workflow) {
2059
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2060
+ }
2061
+ if (workflow.type !== 'workflow') {
2062
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2063
+ }
2064
+ // Optionally hydrate progress
2065
+ if (hydrateProgress) {
2066
+ const progress = await api.getWorkflowProgress(id);
2067
+ return c.json({ ...workflow, _progress: progress });
2068
+ }
2069
+ return c.json(workflow);
2070
+ }
2071
+ catch (error) {
2072
+ console.error('[stoneforge] Failed to get workflow:', error);
2073
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get workflow' } }, 500);
2074
+ }
2075
+ });
2076
+ app.get('/api/workflows/:id/tasks', async (c) => {
2077
+ try {
2078
+ const id = c.req.param('id');
2079
+ const url = new URL(c.req.url);
2080
+ const statusParam = url.searchParams.get('status');
2081
+ const limitParam = url.searchParams.get('limit');
2082
+ const offsetParam = url.searchParams.get('offset');
2083
+ // First verify workflow exists
2084
+ const workflow = await api.get(id);
2085
+ if (!workflow) {
2086
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2087
+ }
2088
+ if (workflow.type !== 'workflow') {
2089
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2090
+ }
2091
+ // Build filter for getTasksInWorkflow
2092
+ const filter = {};
2093
+ if (statusParam) {
2094
+ filter.status = statusParam;
2095
+ }
2096
+ if (limitParam) {
2097
+ filter.limit = parseInt(limitParam, 10);
2098
+ }
2099
+ if (offsetParam) {
2100
+ filter.offset = parseInt(offsetParam, 10);
2101
+ }
2102
+ const tasks = await api.getTasksInWorkflow(id, filter);
2103
+ return c.json(tasks);
2104
+ }
2105
+ catch (error) {
2106
+ console.error('[stoneforge] Failed to get workflow tasks:', error);
2107
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get workflow tasks' } }, 500);
2108
+ }
2109
+ });
2110
+ app.get('/api/workflows/:id/progress', async (c) => {
2111
+ try {
2112
+ const id = c.req.param('id');
2113
+ // First verify workflow exists
2114
+ const workflow = await api.get(id);
2115
+ if (!workflow) {
2116
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2117
+ }
2118
+ if (workflow.type !== 'workflow') {
2119
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2120
+ }
2121
+ const progress = await api.getWorkflowProgress(id);
2122
+ return c.json(progress);
2123
+ }
2124
+ catch (error) {
2125
+ console.error('[stoneforge] Failed to get workflow progress:', error);
2126
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get workflow progress' } }, 500);
2127
+ }
2128
+ });
2129
+ // TB122: Check if a task can be deleted from a workflow
2130
+ app.get('/api/workflows/:id/can-delete-task/:taskId', async (c) => {
2131
+ try {
2132
+ const workflowId = c.req.param('id');
2133
+ const taskId = c.req.param('taskId');
2134
+ // Verify workflow exists
2135
+ const workflow = await api.get(workflowId);
2136
+ if (!workflow) {
2137
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2138
+ }
2139
+ if (workflow.type !== 'workflow') {
2140
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2141
+ }
2142
+ // Get tasks in workflow
2143
+ const tasks = await api.getTasksInWorkflow(workflowId);
2144
+ // Check if this task is in the workflow
2145
+ const taskInWorkflow = tasks.some(t => t.id === taskId);
2146
+ if (!taskInWorkflow) {
2147
+ return c.json({ canDelete: false, reason: 'Task is not in this workflow' });
2148
+ }
2149
+ // Check if this is the last task
2150
+ const isLastTask = tasks.length === 1;
2151
+ if (isLastTask) {
2152
+ return c.json({
2153
+ canDelete: false,
2154
+ reason: "Cannot delete the last task in a workflow. Workflows must have at least one task. Use 'sf workflow delete' to delete the entire workflow.",
2155
+ isLastTask: true
2156
+ });
2157
+ }
2158
+ return c.json({ canDelete: true });
2159
+ }
2160
+ catch (error) {
2161
+ console.error('[stoneforge] Failed to check if task can be deleted:', error);
2162
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to check if task can be deleted' } }, 500);
2163
+ }
2164
+ });
2165
+ app.post('/api/workflows', async (c) => {
2166
+ try {
2167
+ const body = await c.req.json();
2168
+ // Validate required fields
2169
+ if (!body.title || typeof body.title !== 'string') {
2170
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'title is required and must be a string' } }, 400);
2171
+ }
2172
+ if (!body.createdBy || typeof body.createdBy !== 'string') {
2173
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'createdBy is required and must be a string' } }, 400);
2174
+ }
2175
+ // Validate title length
2176
+ if (body.title.length < 1 || body.title.length > 500) {
2177
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'title must be between 1 and 500 characters' } }, 400);
2178
+ }
2179
+ // TB122: Workflows must have at least one task
2180
+ // Accept either:
2181
+ // 1. initialTaskId - existing task to add to the workflow
2182
+ // 2. initialTask - object with task details to create and add
2183
+ const hasInitialTaskId = body.initialTaskId && typeof body.initialTaskId === 'string';
2184
+ const hasInitialTask = body.initialTask && typeof body.initialTask === 'object' && body.initialTask.title;
2185
+ if (!hasInitialTaskId && !hasInitialTask) {
2186
+ return c.json({
2187
+ error: {
2188
+ code: 'VALIDATION_ERROR',
2189
+ message: 'Workflows must have at least one task. Provide either initialTaskId (existing task ID) or initialTask (object with title to create new task).'
2190
+ }
2191
+ }, 400);
2192
+ }
2193
+ // Validate initialTaskId exists if provided
2194
+ if (hasInitialTaskId) {
2195
+ const existingTask = await api.get(body.initialTaskId);
2196
+ if (!existingTask) {
2197
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Initial task not found' } }, 404);
2198
+ }
2199
+ if (existingTask.type !== 'task') {
2200
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'initialTaskId must reference a task' } }, 400);
2201
+ }
2202
+ }
2203
+ // Validate initialTask title if provided
2204
+ if (hasInitialTask) {
2205
+ if (typeof body.initialTask.title !== 'string' || body.initialTask.title.length < 1 || body.initialTask.title.length > 500) {
2206
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'initialTask.title must be between 1 and 500 characters' } }, 400);
2207
+ }
2208
+ }
2209
+ // Create the workflow using the factory function
2210
+ const workflowInput = {
2211
+ title: body.title,
2212
+ createdBy: body.createdBy,
2213
+ status: body.status || 'pending',
2214
+ ephemeral: body.ephemeral ?? false,
2215
+ tags: body.tags || [],
2216
+ variables: body.variables || {},
2217
+ descriptionRef: body.descriptionRef,
2218
+ playbookId: body.playbookId,
2219
+ };
2220
+ const workflow = await createWorkflow(workflowInput);
2221
+ const created = await api.create(workflow);
2222
+ // Now add or create the initial task
2223
+ let taskId;
2224
+ let createdTask = null;
2225
+ if (hasInitialTaskId) {
2226
+ taskId = body.initialTaskId;
2227
+ }
2228
+ else {
2229
+ // Create a new task using the proper factory function
2230
+ const taskInput = {
2231
+ title: body.initialTask.title,
2232
+ status: (body.initialTask.status || 'open'),
2233
+ priority: body.initialTask.priority || 3,
2234
+ complexity: body.initialTask.complexity || 3,
2235
+ tags: body.initialTask.tags || [],
2236
+ createdBy: body.createdBy,
2237
+ };
2238
+ const task = await createTask(taskInput);
2239
+ createdTask = await api.create(task);
2240
+ taskId = createdTask.id;
2241
+ }
2242
+ // Add parent-child dependency from task to workflow
2243
+ await api.addDependency({
2244
+ blockedId: taskId,
2245
+ blockerId: created.id,
2246
+ type: 'parent-child',
2247
+ actor: body.createdBy,
2248
+ });
2249
+ // Return the workflow along with the initial task info
2250
+ return c.json({
2251
+ ...created,
2252
+ initialTask: createdTask || { id: taskId }
2253
+ }, 201);
2254
+ }
2255
+ catch (error) {
2256
+ if (error.code === 'VALIDATION_ERROR') {
2257
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
2258
+ }
2259
+ if (error.code === 'ALREADY_EXISTS') {
2260
+ return c.json({ error: { code: 'ALREADY_EXISTS', message: 'Task is already in another collection' } }, 409);
2261
+ }
2262
+ console.error('[stoneforge] Failed to create workflow:', error);
2263
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to create workflow' } }, 500);
2264
+ }
2265
+ });
2266
+ app.post('/api/workflows/instantiate', async (c) => {
2267
+ try {
2268
+ const body = await c.req.json();
2269
+ // Validate required fields
2270
+ if (!body.playbook || typeof body.playbook !== 'object') {
2271
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'playbook is required and must be an object' } }, 400);
2272
+ }
2273
+ if (!body.createdBy || typeof body.createdBy !== 'string') {
2274
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'createdBy is required and must be a string' } }, 400);
2275
+ }
2276
+ // TB122: Validate playbook has at least one step
2277
+ const playbook = body.playbook;
2278
+ if (!playbook.steps || !Array.isArray(playbook.steps) || playbook.steps.length === 0) {
2279
+ return c.json({
2280
+ error: {
2281
+ code: 'VALIDATION_ERROR',
2282
+ message: 'Cannot instantiate workflow: playbook has no steps defined. Workflows must have at least one task.'
2283
+ }
2284
+ }, 400);
2285
+ }
2286
+ // Build instantiation input
2287
+ const createInput = {
2288
+ playbook: body.playbook,
2289
+ variables: body.variables || {},
2290
+ createdBy: body.createdBy,
2291
+ title: body.title,
2292
+ ephemeral: body.ephemeral ?? false,
2293
+ tags: body.tags || [],
2294
+ metadata: body.metadata || {},
2295
+ };
2296
+ // Instantiate the workflow from playbook
2297
+ const result = await createWorkflowFromPlaybook(createInput);
2298
+ // TB122: Verify at least one task was created (steps may have been filtered by conditions)
2299
+ if (result.tasks.length === 0) {
2300
+ return c.json({
2301
+ error: {
2302
+ code: 'VALIDATION_ERROR',
2303
+ message: 'Cannot instantiate workflow: all playbook steps were filtered by conditions. At least one task must be created.'
2304
+ }
2305
+ }, 400);
2306
+ }
2307
+ // Create the workflow and all tasks in the database
2308
+ const createdWorkflow = await api.create(result.workflow);
2309
+ // Create all tasks
2310
+ const createdTasks = [];
2311
+ for (const task of result.tasks) {
2312
+ const createdTask = await api.create(task.task);
2313
+ createdTasks.push(createdTask);
2314
+ }
2315
+ // Create all dependencies
2316
+ for (const dep of [...result.blocksDependencies, ...result.parentChildDependencies]) {
2317
+ await api.addDependency(dep);
2318
+ }
2319
+ return c.json({
2320
+ workflow: createdWorkflow,
2321
+ tasks: createdTasks,
2322
+ skippedSteps: result.skippedSteps,
2323
+ resolvedVariables: result.resolvedVariables,
2324
+ }, 201);
2325
+ }
2326
+ catch (error) {
2327
+ if (error.code === 'VALIDATION_ERROR') {
2328
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
2329
+ }
2330
+ console.error('[stoneforge] Failed to instantiate workflow:', error);
2331
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to instantiate workflow' } }, 500);
2332
+ }
2333
+ });
2334
+ app.patch('/api/workflows/:id', async (c) => {
2335
+ try {
2336
+ const id = c.req.param('id');
2337
+ const body = await c.req.json();
2338
+ // First verify workflow exists
2339
+ const existing = await api.get(id);
2340
+ if (!existing) {
2341
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2342
+ }
2343
+ if (existing.type !== 'workflow') {
2344
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2345
+ }
2346
+ // Extract allowed updates
2347
+ const updates = {};
2348
+ const allowedFields = ['title', 'status', 'tags', 'metadata', 'descriptionRef', 'failureReason', 'cancelReason'];
2349
+ for (const field of allowedFields) {
2350
+ if (body[field] !== undefined) {
2351
+ updates[field] = body[field];
2352
+ }
2353
+ }
2354
+ // Validate title if provided
2355
+ if (updates.title !== undefined) {
2356
+ if (typeof updates.title !== 'string' || updates.title.length < 1 || updates.title.length > 500) {
2357
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'title must be between 1 and 500 characters' } }, 400);
2358
+ }
2359
+ }
2360
+ // Validate status if provided
2361
+ if (updates.status !== undefined) {
2362
+ const validStatuses = ['pending', 'running', 'completed', 'failed', 'cancelled'];
2363
+ if (!validStatuses.includes(updates.status)) {
2364
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: `Invalid status. Must be one of: ${validStatuses.join(', ')}` } }, 400);
2365
+ }
2366
+ }
2367
+ const updated = await api.update(id, updates);
2368
+ return c.json(updated);
2369
+ }
2370
+ catch (error) {
2371
+ if (error.code === 'NOT_FOUND') {
2372
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2373
+ }
2374
+ if (error.code === 'VALIDATION_ERROR') {
2375
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
2376
+ }
2377
+ console.error('[stoneforge] Failed to update workflow:', error);
2378
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to update workflow' } }, 500);
2379
+ }
2380
+ });
2381
+ // Delete workflow (delete ephemeral workflow and all its tasks)
2382
+ app.delete('/api/workflows/:id', async (c) => {
2383
+ try {
2384
+ const id = c.req.param('id');
2385
+ const url = new URL(c.req.url);
2386
+ const force = url.searchParams.get('force') === 'true';
2387
+ // Verify workflow exists
2388
+ const workflow = await api.get(id);
2389
+ if (!workflow) {
2390
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2391
+ }
2392
+ if (workflow.type !== 'workflow') {
2393
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2394
+ }
2395
+ // Check if workflow is ephemeral (unless force is specified)
2396
+ if (!workflow.ephemeral && !force) {
2397
+ return c.json({
2398
+ error: {
2399
+ code: 'VALIDATION_ERROR',
2400
+ message: 'Cannot delete durable workflow. Use force=true to override.',
2401
+ },
2402
+ }, 400);
2403
+ }
2404
+ // Delete the workflow and its tasks
2405
+ const result = await api.deleteWorkflow(id);
2406
+ return c.json(result);
2407
+ }
2408
+ catch (error) {
2409
+ if (error.code === 'NOT_FOUND') {
2410
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2411
+ }
2412
+ console.error('[stoneforge] Failed to delete workflow:', error);
2413
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete workflow' } }, 500);
2414
+ }
2415
+ });
2416
+ // Promote workflow (promote ephemeral to durable)
2417
+ app.post('/api/workflows/:id/promote', async (c) => {
2418
+ try {
2419
+ const id = c.req.param('id');
2420
+ // Verify workflow exists
2421
+ const workflow = await api.get(id);
2422
+ if (!workflow) {
2423
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2424
+ }
2425
+ if (workflow.type !== 'workflow') {
2426
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2427
+ }
2428
+ // Check if workflow is ephemeral
2429
+ if (!workflow.ephemeral) {
2430
+ return c.json({
2431
+ error: {
2432
+ code: 'VALIDATION_ERROR',
2433
+ message: 'Workflow is already durable',
2434
+ },
2435
+ }, 400);
2436
+ }
2437
+ // Promote to durable by setting ephemeral to false
2438
+ const updated = await api.update(id, { ephemeral: false });
2439
+ return c.json(updated);
2440
+ }
2441
+ catch (error) {
2442
+ if (error.code === 'NOT_FOUND') {
2443
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Workflow not found' } }, 404);
2444
+ }
2445
+ if (error.code === 'VALIDATION_ERROR') {
2446
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: error.message } }, 400);
2447
+ }
2448
+ console.error('[stoneforge] Failed to promote workflow:', error);
2449
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to promote workflow' } }, 500);
2450
+ }
2451
+ });
2452
+ // ============================================================================
2453
+ // Playbook Endpoints
2454
+ // ============================================================================
2455
+ // Default playbook search paths
2456
+ const PLAYBOOK_SEARCH_PATHS = [
2457
+ resolve(PROJECT_ROOT, '.stoneforge/playbooks'),
2458
+ resolve(PROJECT_ROOT, 'playbooks'),
2459
+ ];
2460
+ app.get('/api/playbooks', async (c) => {
2461
+ try {
2462
+ const discovered = discoverPlaybookFiles(PLAYBOOK_SEARCH_PATHS, { recursive: true });
2463
+ // Return basic info about discovered playbooks
2464
+ const playbooks = discovered.map((p) => ({
2465
+ name: p.name,
2466
+ path: p.path,
2467
+ directory: p.directory,
2468
+ }));
2469
+ return c.json(playbooks);
2470
+ }
2471
+ catch (error) {
2472
+ console.error('[stoneforge] Failed to list playbooks:', error);
2473
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to list playbooks' } }, 500);
2474
+ }
2475
+ });
2476
+ app.get('/api/playbooks/:name', async (c) => {
2477
+ try {
2478
+ const name = c.req.param('name');
2479
+ const discovered = discoverPlaybookFiles(PLAYBOOK_SEARCH_PATHS, { recursive: true });
2480
+ // Find the playbook by name
2481
+ const found = discovered.find((p) => p.name.toLowerCase() === name.toLowerCase());
2482
+ if (!found) {
2483
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Playbook not found' } }, 404);
2484
+ }
2485
+ // Load the full playbook
2486
+ const playbookInput = loadPlaybookFromFile(found.path, 'system');
2487
+ // Create a Playbook object to return (without actually storing it)
2488
+ const playbook = createPlaybook(playbookInput);
2489
+ return c.json({
2490
+ ...playbook,
2491
+ filePath: found.path,
2492
+ directory: found.directory,
2493
+ });
2494
+ }
2495
+ catch (error) {
2496
+ console.error('[stoneforge] Failed to get playbook:', error);
2497
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get playbook' } }, 500);
2498
+ }
2499
+ });
2500
+ // ============================================================================
2501
+ // Teams Endpoints
2502
+ // ============================================================================
2503
+ app.get('/api/teams', async (c) => {
2504
+ try {
2505
+ const url = new URL(c.req.url);
2506
+ // Parse pagination and filter parameters
2507
+ const limitParam = url.searchParams.get('limit');
2508
+ const offsetParam = url.searchParams.get('offset');
2509
+ const orderByParam = url.searchParams.get('orderBy');
2510
+ const orderDirParam = url.searchParams.get('orderDir');
2511
+ const searchParam = url.searchParams.get('search');
2512
+ // Build filter
2513
+ const filter = {
2514
+ type: 'team',
2515
+ };
2516
+ if (limitParam) {
2517
+ filter.limit = parseInt(limitParam, 10);
2518
+ }
2519
+ else {
2520
+ filter.limit = 50; // Default page size
2521
+ }
2522
+ if (offsetParam) {
2523
+ filter.offset = parseInt(offsetParam, 10);
2524
+ }
2525
+ if (orderByParam) {
2526
+ filter.orderBy = orderByParam;
2527
+ }
2528
+ else {
2529
+ filter.orderBy = 'updated_at';
2530
+ }
2531
+ if (orderDirParam) {
2532
+ filter.orderDir = orderDirParam;
2533
+ }
2534
+ else {
2535
+ filter.orderDir = 'desc';
2536
+ }
2537
+ // Get paginated results
2538
+ const result = await api.listPaginated(filter);
2539
+ // Apply client-side filtering for search (not supported in base filter)
2540
+ let filteredItems = result.items;
2541
+ if (searchParam) {
2542
+ const query = searchParam.toLowerCase();
2543
+ filteredItems = filteredItems.filter((t) => {
2544
+ const team = t;
2545
+ return (team.name.toLowerCase().includes(query) ||
2546
+ team.id.toLowerCase().includes(query) ||
2547
+ (team.tags || []).some((tag) => tag.toLowerCase().includes(query)));
2548
+ });
2549
+ }
2550
+ // Return paginated response format
2551
+ return c.json({
2552
+ items: filteredItems,
2553
+ total: result.total,
2554
+ offset: result.offset,
2555
+ limit: result.limit,
2556
+ hasMore: result.hasMore,
2557
+ });
2558
+ }
2559
+ catch (error) {
2560
+ console.error('[stoneforge] Failed to get teams:', error);
2561
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get teams' } }, 500);
2562
+ }
2563
+ });
2564
+ app.post('/api/teams', async (c) => {
2565
+ try {
2566
+ const body = await c.req.json();
2567
+ const { name, members, createdBy, tags, metadata, descriptionRef } = body;
2568
+ // Validation
2569
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
2570
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Name is required' } }, 400);
2571
+ }
2572
+ // Validate members array - TB123: Teams must have at least one member
2573
+ if (!members || !Array.isArray(members) || members.length === 0) {
2574
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Teams must have at least one member' } }, 400);
2575
+ }
2576
+ // Check each member is a valid string
2577
+ for (const member of members) {
2578
+ if (typeof member !== 'string' || member.length === 0) {
2579
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Each member must be a valid entity ID' } }, 400);
2580
+ }
2581
+ }
2582
+ // Check for duplicate members
2583
+ const uniqueMembers = new Set(members);
2584
+ if (uniqueMembers.size !== members.length) {
2585
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Duplicate members are not allowed' } }, 400);
2586
+ }
2587
+ // Check for duplicate team name
2588
+ const existingTeams = await api.list({ type: 'team' });
2589
+ const duplicateName = existingTeams.some((t) => {
2590
+ const team = t;
2591
+ return team.name.toLowerCase() === name.toLowerCase().trim();
2592
+ });
2593
+ if (duplicateName) {
2594
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Team with this name already exists' } }, 400);
2595
+ }
2596
+ const teamInput = {
2597
+ name: name.trim(),
2598
+ members: members || [],
2599
+ createdBy: (createdBy || 'el-0000'),
2600
+ tags: tags || [],
2601
+ metadata: metadata || {},
2602
+ ...(descriptionRef !== undefined && { descriptionRef }),
2603
+ };
2604
+ const team = await createTeam(teamInput);
2605
+ const created = await api.create(team);
2606
+ return c.json(created, 201);
2607
+ }
2608
+ catch (error) {
2609
+ console.error('[stoneforge] Failed to create team:', error);
2610
+ const errorMessage = error instanceof Error ? error.message : 'Failed to create team';
2611
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: errorMessage } }, 500);
2612
+ }
2613
+ });
2614
+ app.get('/api/teams/:id', async (c) => {
2615
+ try {
2616
+ const id = c.req.param('id');
2617
+ const team = await api.get(id);
2618
+ if (!team || team.type !== 'team') {
2619
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Team not found' } }, 404);
2620
+ }
2621
+ return c.json(team);
2622
+ }
2623
+ catch (error) {
2624
+ console.error('[stoneforge] Failed to get team:', error);
2625
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get team' } }, 500);
2626
+ }
2627
+ });
2628
+ app.patch('/api/teams/:id', async (c) => {
2629
+ try {
2630
+ const id = c.req.param('id');
2631
+ const body = await c.req.json();
2632
+ const { name, tags, addMembers, removeMembers } = body;
2633
+ // Verify team exists
2634
+ const existing = await api.get(id);
2635
+ if (!existing || existing.type !== 'team') {
2636
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Team not found' } }, 404);
2637
+ }
2638
+ const existingTeam = existing;
2639
+ // Build updates object
2640
+ const updates = {};
2641
+ if (name !== undefined) {
2642
+ // Validate name format
2643
+ if (typeof name !== 'string' || name.trim().length === 0) {
2644
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Name must be a non-empty string' } }, 400);
2645
+ }
2646
+ // Check for duplicate name (if changing)
2647
+ if (name.trim() !== existingTeam.name) {
2648
+ const existingTeams = await api.list({ type: 'team' });
2649
+ const duplicateName = existingTeams.some((t) => {
2650
+ const team = t;
2651
+ return team.name.toLowerCase() === name.toLowerCase().trim() && team.id !== id;
2652
+ });
2653
+ if (duplicateName) {
2654
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Team with this name already exists' } }, 400);
2655
+ }
2656
+ }
2657
+ updates.name = name.trim();
2658
+ }
2659
+ if (tags !== undefined) {
2660
+ if (!Array.isArray(tags)) {
2661
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Tags must be an array' } }, 400);
2662
+ }
2663
+ updates.tags = tags;
2664
+ }
2665
+ // Handle member additions/removals
2666
+ let currentMembers = [...existingTeam.members];
2667
+ if (addMembers !== undefined) {
2668
+ if (!Array.isArray(addMembers)) {
2669
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'addMembers must be an array' } }, 400);
2670
+ }
2671
+ for (const memberId of addMembers) {
2672
+ if (typeof memberId !== 'string' || memberId.length === 0) {
2673
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Each member ID must be a non-empty string' } }, 400);
2674
+ }
2675
+ if (!currentMembers.includes(memberId)) {
2676
+ currentMembers.push(memberId);
2677
+ }
2678
+ }
2679
+ }
2680
+ if (removeMembers !== undefined) {
2681
+ if (!Array.isArray(removeMembers)) {
2682
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'removeMembers must be an array' } }, 400);
2683
+ }
2684
+ for (const memberId of removeMembers) {
2685
+ if (typeof memberId !== 'string' || memberId.length === 0) {
2686
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Each member ID must be a non-empty string' } }, 400);
2687
+ }
2688
+ currentMembers = currentMembers.filter((m) => m !== memberId);
2689
+ }
2690
+ }
2691
+ // TB123: Prevent removing the last member - teams must have at least one member
2692
+ if (addMembers !== undefined || removeMembers !== undefined) {
2693
+ if (currentMembers.length === 0) {
2694
+ return c.json({
2695
+ error: {
2696
+ code: 'VALIDATION_ERROR',
2697
+ message: 'Cannot remove the last member from a team. Teams must have at least one member.'
2698
+ }
2699
+ }, 400);
2700
+ }
2701
+ updates.members = currentMembers;
2702
+ }
2703
+ if (Object.keys(updates).length === 0) {
2704
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'No valid updates provided' } }, 400);
2705
+ }
2706
+ const updated = await api.update(id, updates);
2707
+ return c.json(updated);
2708
+ }
2709
+ catch (error) {
2710
+ console.error('[stoneforge] Failed to update team:', error);
2711
+ const errorMessage = error instanceof Error ? error.message : 'Failed to update team';
2712
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: errorMessage } }, 500);
2713
+ }
2714
+ });
2715
+ app.delete('/api/teams/:id', async (c) => {
2716
+ try {
2717
+ const id = c.req.param('id');
2718
+ // Verify team exists
2719
+ const existing = await api.get(id);
2720
+ if (!existing || existing.type !== 'team') {
2721
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Team not found' } }, 404);
2722
+ }
2723
+ // Soft-delete the team
2724
+ await api.delete(id);
2725
+ return c.json({ success: true, id });
2726
+ }
2727
+ catch (error) {
2728
+ if (error.code === 'NOT_FOUND') {
2729
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Team not found' } }, 404);
2730
+ }
2731
+ console.error('[stoneforge] Failed to delete team:', error);
2732
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete team' } }, 500);
2733
+ }
2734
+ });
2735
+ app.get('/api/teams/:id/members', async (c) => {
2736
+ try {
2737
+ const id = c.req.param('id');
2738
+ const team = await api.get(id);
2739
+ if (!team || team.type !== 'team') {
2740
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Team not found' } }, 404);
2741
+ }
2742
+ // Get member IDs from the team
2743
+ const teamData = team;
2744
+ const memberIds = teamData.members || [];
2745
+ // Fetch each member entity
2746
+ const members = [];
2747
+ for (const memberId of memberIds) {
2748
+ try {
2749
+ const member = await api.get(memberId);
2750
+ if (member && member.type === 'entity') {
2751
+ members.push(member);
2752
+ }
2753
+ }
2754
+ catch {
2755
+ // Skip members that can't be fetched
2756
+ }
2757
+ }
2758
+ return c.json(members);
2759
+ }
2760
+ catch (error) {
2761
+ console.error('[stoneforge] Failed to get team members:', error);
2762
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get team members' } }, 500);
2763
+ }
2764
+ });
2765
+ app.get('/api/teams/:id/stats', async (c) => {
2766
+ try {
2767
+ const id = c.req.param('id');
2768
+ const team = await api.get(id);
2769
+ if (!team || team.type !== 'team') {
2770
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Team not found' } }, 404);
2771
+ }
2772
+ const teamData = team;
2773
+ const memberIds = teamData.members || [];
2774
+ // Get all tasks to calculate team stats
2775
+ const allTasks = await api.list({ type: 'task' });
2776
+ // Calculate stats for the team
2777
+ let totalTasksAssigned = 0;
2778
+ let activeTasksAssigned = 0;
2779
+ let completedTasksAssigned = 0;
2780
+ let createdByTeamMembers = 0;
2781
+ const tasksByMember = {};
2782
+ // Initialize member stats
2783
+ for (const memberId of memberIds) {
2784
+ tasksByMember[memberId] = { assigned: 0, active: 0, completed: 0 };
2785
+ }
2786
+ for (const task of allTasks) {
2787
+ const taskData = task;
2788
+ // Check if task is assigned to a team member
2789
+ if (taskData.assignee && memberIds.includes(taskData.assignee)) {
2790
+ totalTasksAssigned++;
2791
+ const memberKey = taskData.assignee;
2792
+ if (tasksByMember[memberKey]) {
2793
+ tasksByMember[memberKey].assigned++;
2794
+ }
2795
+ const status = taskData.status || 'open';
2796
+ if (status === 'closed') {
2797
+ completedTasksAssigned++;
2798
+ if (tasksByMember[memberKey]) {
2799
+ tasksByMember[memberKey].completed++;
2800
+ }
2801
+ }
2802
+ else if (status !== 'tombstone') {
2803
+ activeTasksAssigned++;
2804
+ if (tasksByMember[memberKey]) {
2805
+ tasksByMember[memberKey].active++;
2806
+ }
2807
+ }
2808
+ }
2809
+ // Check if task was created by a team member
2810
+ if (taskData.createdBy && memberIds.includes(taskData.createdBy)) {
2811
+ createdByTeamMembers++;
2812
+ }
2813
+ }
2814
+ // Calculate workload distribution (tasks per member as percentages)
2815
+ const workloadDistribution = [];
2816
+ for (const memberId of memberIds) {
2817
+ const memberStats = tasksByMember[memberId];
2818
+ if (memberStats) {
2819
+ const percentage = totalTasksAssigned > 0
2820
+ ? Math.round((memberStats.assigned / totalTasksAssigned) * 100)
2821
+ : 0;
2822
+ workloadDistribution.push({
2823
+ memberId: memberId,
2824
+ taskCount: memberStats.assigned,
2825
+ percentage,
2826
+ });
2827
+ }
2828
+ }
2829
+ return c.json({
2830
+ memberCount: memberIds.length,
2831
+ totalTasksAssigned,
2832
+ activeTasksAssigned,
2833
+ completedTasksAssigned,
2834
+ createdByTeamMembers,
2835
+ tasksByMember,
2836
+ workloadDistribution,
2837
+ });
2838
+ }
2839
+ catch (error) {
2840
+ console.error('[stoneforge] Failed to get team stats:', error);
2841
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get team stats' } }, 500);
2842
+ }
2843
+ });
2844
+ // TB123: Check if a member can be removed from a team
2845
+ app.get('/api/teams/:id/can-remove-member/:entityId', async (c) => {
2846
+ try {
2847
+ const id = c.req.param('id');
2848
+ const entityId = c.req.param('entityId');
2849
+ const team = await api.get(id);
2850
+ if (!team || team.type !== 'team') {
2851
+ return c.json({ error: { code: 'NOT_FOUND', message: 'Team not found' } }, 404);
2852
+ }
2853
+ const teamData = team;
2854
+ const memberIds = teamData.members || [];
2855
+ // Check if entity is a member
2856
+ if (!memberIds.includes(entityId)) {
2857
+ return c.json({
2858
+ canRemove: false,
2859
+ reason: 'Entity is not a member of this team',
2860
+ });
2861
+ }
2862
+ // Check if this is the last member
2863
+ if (memberIds.length <= 1) {
2864
+ return c.json({
2865
+ canRemove: false,
2866
+ reason: 'Cannot remove the last member from a team. Teams must have at least one member.',
2867
+ });
2868
+ }
2869
+ return c.json({
2870
+ canRemove: true,
2871
+ reason: null,
2872
+ });
2873
+ }
2874
+ catch (error) {
2875
+ console.error('[stoneforge] Failed to check can-remove-member:', error);
2876
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to check member removal' } }, 500);
2877
+ }
2878
+ });
2879
+ // ============================================================================
2880
+ // Sync Endpoints
2881
+ // ============================================================================
2882
+ app.get('/api/sync/status', async (c) => {
2883
+ try {
2884
+ const dirtyElements = storageBackend.getDirtyElements();
2885
+ return c.json({
2886
+ dirtyElementCount: dirtyElements.length,
2887
+ dirtyDependencyCount: 0, // Not tracked separately currently
2888
+ hasPendingChanges: dirtyElements.length > 0,
2889
+ exportPath: resolve(PROJECT_ROOT, '.stoneforge'),
2890
+ });
2891
+ }
2892
+ catch (error) {
2893
+ console.error('[stoneforge] Failed to get sync status:', error);
2894
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get sync status' } }, 500);
2895
+ }
2896
+ });
2897
+ app.post('/api/sync/export', async (c) => {
2898
+ try {
2899
+ const body = await c.req.json().catch(() => ({}));
2900
+ const includeEphemeral = body.includeEphemeral ?? false;
2901
+ // Export to JSONL files in .stoneforge directory
2902
+ const result = await syncService.export({
2903
+ outputDir: resolve(PROJECT_ROOT, '.stoneforge'),
2904
+ full: true,
2905
+ includeEphemeral,
2906
+ });
2907
+ return c.json({
2908
+ success: true,
2909
+ elementsExported: result.elementsExported,
2910
+ dependenciesExported: result.dependenciesExported,
2911
+ elementsFile: result.elementsFile,
2912
+ dependenciesFile: result.dependenciesFile,
2913
+ exportedAt: result.exportedAt,
2914
+ });
2915
+ }
2916
+ catch (error) {
2917
+ console.error('[stoneforge] Failed to export:', error);
2918
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to export data' } }, 500);
2919
+ }
2920
+ });
2921
+ app.post('/api/sync/import', async (c) => {
2922
+ try {
2923
+ const body = await c.req.json();
2924
+ // Validate request
2925
+ if (!body.elements || typeof body.elements !== 'string') {
2926
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'elements field is required and must be a JSONL string' } }, 400);
2927
+ }
2928
+ const result = syncService.importFromStrings(body.elements, body.dependencies ?? '', {
2929
+ dryRun: body.dryRun ?? false,
2930
+ force: body.force ?? false,
2931
+ });
2932
+ return c.json({
2933
+ success: true,
2934
+ elementsImported: result.elementsImported,
2935
+ elementsSkipped: result.elementsSkipped,
2936
+ dependenciesImported: result.dependenciesImported,
2937
+ dependenciesSkipped: result.dependenciesSkipped,
2938
+ conflicts: result.conflicts,
2939
+ errors: result.errors,
2940
+ importedAt: result.importedAt,
2941
+ });
2942
+ }
2943
+ catch (error) {
2944
+ console.error('[stoneforge] Failed to import:', error);
2945
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to import data' } }, 500);
2946
+ }
2947
+ });
2948
+ // ============================================================================
2949
+ // Uploads Endpoints (TB94e - Image Support)
2950
+ // ============================================================================
2951
+ /**
2952
+ * Ensure uploads directory exists
2953
+ */
2954
+ async function ensureUploadsDir() {
2955
+ try {
2956
+ await mkdir(UPLOADS_DIR, { recursive: true });
2957
+ }
2958
+ catch {
2959
+ // Directory may already exist, which is fine
2960
+ }
2961
+ }
2962
+ /**
2963
+ * POST /api/uploads
2964
+ * Upload an image file. Returns the URL to access the uploaded file.
2965
+ *
2966
+ * Accepts multipart/form-data with:
2967
+ * - file: The image file (required)
2968
+ *
2969
+ * Returns:
2970
+ * - { url: string, filename: string, size: number, mimeType: string }
2971
+ */
2972
+ app.post('/api/uploads', async (c) => {
2973
+ try {
2974
+ await ensureUploadsDir();
2975
+ // Parse form data
2976
+ const formData = await c.req.formData();
2977
+ const file = formData.get('file');
2978
+ if (!file || !(file instanceof File)) {
2979
+ return c.json({
2980
+ error: { code: 'VALIDATION_ERROR', message: 'No file provided. Use multipart/form-data with a "file" field.' }
2981
+ }, 400);
2982
+ }
2983
+ // Validate file type
2984
+ const mimeType = file.type;
2985
+ if (!ALLOWED_MIME_TYPES.includes(mimeType)) {
2986
+ return c.json({
2987
+ error: {
2988
+ code: 'VALIDATION_ERROR',
2989
+ message: `Invalid file type: ${mimeType}. Allowed types: ${ALLOWED_MIME_TYPES.join(', ')}`
2990
+ }
2991
+ }, 400);
2992
+ }
2993
+ // Validate file size
2994
+ if (file.size > MAX_UPLOAD_SIZE) {
2995
+ return c.json({
2996
+ error: {
2997
+ code: 'VALIDATION_ERROR',
2998
+ message: `File too large: ${(file.size / (1024 * 1024)).toFixed(2)}MB. Maximum size: 10MB`
2999
+ }
3000
+ }, 400);
3001
+ }
3002
+ // Read file content
3003
+ const arrayBuffer = await file.arrayBuffer();
3004
+ const buffer = Buffer.from(arrayBuffer);
3005
+ // Generate hash-based filename for deduplication
3006
+ const hash = createHash('sha256').update(buffer).digest('hex').slice(0, 16);
3007
+ const ext = MIME_TO_EXT[mimeType] || extname(file.name) || '.bin';
3008
+ const filename = `${hash}${ext}`;
3009
+ const filepath = resolve(UPLOADS_DIR, filename);
3010
+ // Write file to disk
3011
+ await Bun.write(filepath, buffer);
3012
+ console.log(`[stoneforge] Uploaded image: ${filename} (${file.size} bytes)`);
3013
+ return c.json({
3014
+ url: `/api/uploads/${filename}`,
3015
+ filename,
3016
+ size: file.size,
3017
+ mimeType,
3018
+ }, 201);
3019
+ }
3020
+ catch (error) {
3021
+ console.error('[stoneforge] Failed to upload file:', error);
3022
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to upload file' } }, 500);
3023
+ }
3024
+ });
3025
+ /**
3026
+ * GET /api/uploads/:filename/usage
3027
+ * Track which documents reference a specific image.
3028
+ * Scans all documents for image URLs containing the filename.
3029
+ * NOTE: This route MUST be defined before /api/uploads/:filename to take precedence.
3030
+ */
3031
+ app.get('/api/uploads/:filename/usage', async (c) => {
3032
+ try {
3033
+ const filename = c.req.param('filename');
3034
+ // Security: prevent directory traversal
3035
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
3036
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid filename' } }, 400);
3037
+ }
3038
+ // Check if file exists
3039
+ const filepath = resolve(UPLOADS_DIR, filename);
3040
+ const file = Bun.file(filepath);
3041
+ const exists = await file.exists();
3042
+ if (!exists) {
3043
+ return c.json({ error: { code: 'NOT_FOUND', message: 'File not found' } }, 404);
3044
+ }
3045
+ // Search for documents that reference this image
3046
+ // Look for the filename in document content (images are stored as Markdown ![alt](url))
3047
+ const documents = await api.list({ type: 'document' });
3048
+ const usedIn = [];
3049
+ for (const element of documents) {
3050
+ // Check if document content contains the filename
3051
+ // Images can be referenced as /api/uploads/filename or http://localhost:3456/api/uploads/filename
3052
+ const doc = element;
3053
+ if (doc.content && typeof doc.content === 'string') {
3054
+ if (doc.content.includes(`/api/uploads/${filename}`) || doc.content.includes(filename)) {
3055
+ usedIn.push({
3056
+ id: doc.id,
3057
+ title: doc.title || 'Untitled',
3058
+ });
3059
+ }
3060
+ }
3061
+ }
3062
+ return c.json({
3063
+ filename,
3064
+ count: usedIn.length,
3065
+ documents: usedIn,
3066
+ });
3067
+ }
3068
+ catch (error) {
3069
+ console.error('[stoneforge] Failed to get upload usage:', error);
3070
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to get upload usage' } }, 500);
3071
+ }
3072
+ });
3073
+ /**
3074
+ * GET /api/uploads/:filename
3075
+ * Serve an uploaded file.
3076
+ */
3077
+ app.get('/api/uploads/:filename', async (c) => {
3078
+ try {
3079
+ const filename = c.req.param('filename');
3080
+ // Security: prevent directory traversal
3081
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
3082
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid filename' } }, 400);
3083
+ }
3084
+ const filepath = resolve(UPLOADS_DIR, filename);
3085
+ // Check if file exists
3086
+ const file = Bun.file(filepath);
3087
+ const exists = await file.exists();
3088
+ if (!exists) {
3089
+ return c.json({ error: { code: 'NOT_FOUND', message: 'File not found' } }, 404);
3090
+ }
3091
+ // Determine content type from extension
3092
+ const ext = extname(filename).toLowerCase();
3093
+ const contentTypeMap = {
3094
+ '.jpg': 'image/jpeg',
3095
+ '.jpeg': 'image/jpeg',
3096
+ '.png': 'image/png',
3097
+ '.gif': 'image/gif',
3098
+ '.webp': 'image/webp',
3099
+ '.svg': 'image/svg+xml',
3100
+ };
3101
+ const contentType = contentTypeMap[ext] || 'application/octet-stream';
3102
+ // Read and return file
3103
+ const arrayBuffer = await file.arrayBuffer();
3104
+ return new Response(arrayBuffer, {
3105
+ headers: {
3106
+ 'Content-Type': contentType,
3107
+ 'Cache-Control': 'public, max-age=31536000, immutable', // Cache for 1 year (immutable since hash-named)
3108
+ },
3109
+ });
3110
+ }
3111
+ catch (error) {
3112
+ console.error('[stoneforge] Failed to serve file:', error);
3113
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to serve file' } }, 500);
3114
+ }
3115
+ });
3116
+ /**
3117
+ * GET /api/uploads
3118
+ * List all uploaded files with metadata.
3119
+ */
3120
+ app.get('/api/uploads', async (c) => {
3121
+ try {
3122
+ await ensureUploadsDir();
3123
+ const files = await readdir(UPLOADS_DIR);
3124
+ // Get file info for each file
3125
+ const fileInfos = await Promise.all(files.map(async (filename) => {
3126
+ try {
3127
+ const filepath = resolve(UPLOADS_DIR, filename);
3128
+ const stats = await stat(filepath);
3129
+ const ext = extname(filename).toLowerCase();
3130
+ const contentTypeMap = {
3131
+ '.jpg': 'image/jpeg',
3132
+ '.jpeg': 'image/jpeg',
3133
+ '.png': 'image/png',
3134
+ '.gif': 'image/gif',
3135
+ '.webp': 'image/webp',
3136
+ '.svg': 'image/svg+xml',
3137
+ };
3138
+ return {
3139
+ filename,
3140
+ url: `/api/uploads/${filename}`,
3141
+ size: stats.size,
3142
+ mimeType: contentTypeMap[ext] || 'application/octet-stream',
3143
+ createdAt: stats.birthtime.toISOString(),
3144
+ modifiedAt: stats.mtime.toISOString(),
3145
+ };
3146
+ }
3147
+ catch {
3148
+ return null;
3149
+ }
3150
+ }));
3151
+ // Filter out any failed reads and sort by creation time (newest first)
3152
+ const validFiles = fileInfos
3153
+ .filter((f) => f !== null)
3154
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
3155
+ return c.json({
3156
+ files: validFiles,
3157
+ total: validFiles.length,
3158
+ });
3159
+ }
3160
+ catch (error) {
3161
+ console.error('[stoneforge] Failed to list uploads:', error);
3162
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to list uploads' } }, 500);
3163
+ }
3164
+ });
3165
+ /**
3166
+ * DELETE /api/uploads/:filename
3167
+ * Delete an uploaded file.
3168
+ */
3169
+ app.delete('/api/uploads/:filename', async (c) => {
3170
+ try {
3171
+ const filename = c.req.param('filename');
3172
+ // Security: prevent directory traversal
3173
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
3174
+ return c.json({ error: { code: 'VALIDATION_ERROR', message: 'Invalid filename' } }, 400);
3175
+ }
3176
+ const filepath = resolve(UPLOADS_DIR, filename);
3177
+ // Check if file exists
3178
+ const file = Bun.file(filepath);
3179
+ const exists = await file.exists();
3180
+ if (!exists) {
3181
+ return c.json({ error: { code: 'NOT_FOUND', message: 'File not found' } }, 404);
3182
+ }
3183
+ // Delete the file
3184
+ await unlink(filepath);
3185
+ console.log(`[stoneforge] Deleted upload: ${filename}`);
3186
+ return c.json({ success: true, filename });
3187
+ }
3188
+ catch (error) {
3189
+ console.error('[stoneforge] Failed to delete upload:', error);
3190
+ return c.json({ error: { code: 'INTERNAL_ERROR', message: 'Failed to delete upload' } }, 500);
3191
+ }
3192
+ });
3193
+ // Return the app and services
3194
+ return { app, api, syncService, inboxService, broadcaster, storageBackend };
3195
+ }
3196
+ // ============================================================================
3197
+ // Dual-runtime server starters
3198
+ // ============================================================================
3199
+ const isBun = typeof globalThis.Bun !== 'undefined';
3200
+ function startBunServer(app, options, wsHandlers) {
3201
+ const Bun = globalThis.Bun;
3202
+ const server = Bun.serve({
3203
+ port: options.port,
3204
+ hostname: options.host,
3205
+ fetch(request, server) {
3206
+ // Handle WS upgrade
3207
+ const url = new URL(request.url);
3208
+ if (url.pathname === '/ws') {
3209
+ const upgradeHeader = request.headers.get('Upgrade');
3210
+ if (upgradeHeader?.toLowerCase() === 'websocket') {
3211
+ const success = server.upgrade(request, { data: {} });
3212
+ if (success)
3213
+ return undefined;
3214
+ return new Response('WebSocket upgrade failed', { status: 500 });
3215
+ }
3216
+ }
3217
+ return app.fetch(request);
3218
+ },
3219
+ websocket: {
3220
+ open(ws) { wsHandlers.handleOpen(ws); },
3221
+ message(ws, message) { wsHandlers.handleMessage(ws, message); },
3222
+ close(ws) { wsHandlers.handleClose(ws); },
3223
+ error(ws, error) { wsHandlers.handleError(ws, error); },
3224
+ },
3225
+ });
3226
+ console.log(`[stoneforge] Bun server listening on http://${options.host}:${server.port}`);
3227
+ return server;
3228
+ }
3229
+ function startNodeServer(app, options, wsHandlers) {
3230
+ import('ws').then(({ WebSocketServer }) => {
3231
+ import('http').then(({ createServer }) => {
3232
+ const httpServer = createServer(async (req, res) => {
3233
+ const url = `http://${options.host}:${options.port}${req.url || '/'}`;
3234
+ const headers = new Headers();
3235
+ for (const [key, value] of Object.entries(req.headers)) {
3236
+ if (value)
3237
+ headers.set(key, Array.isArray(value) ? value.join(', ') : value);
3238
+ }
3239
+ const body = await new Promise((resolve) => {
3240
+ const chunks = [];
3241
+ req.on('data', (chunk) => chunks.push(chunk));
3242
+ req.on('end', () => resolve(Buffer.concat(chunks)));
3243
+ });
3244
+ const request = new Request(url, {
3245
+ method: req.method,
3246
+ headers,
3247
+ body: ['GET', 'HEAD'].includes(req.method || '') ? undefined : body,
3248
+ });
3249
+ try {
3250
+ const response = await app.fetch(request);
3251
+ res.writeHead(response.status, Object.fromEntries(response.headers.entries()));
3252
+ const arrayBuffer = await response.arrayBuffer();
3253
+ res.end(Buffer.from(arrayBuffer));
3254
+ }
3255
+ catch (err) {
3256
+ console.error('[stoneforge] Request error:', err);
3257
+ res.writeHead(500).end('Internal Server Error');
3258
+ }
3259
+ });
3260
+ const wss = new WebSocketServer({ noServer: true });
3261
+ wss.on('connection', (ws) => {
3262
+ // Create an adapter matching the ServerWebSocket<ClientData> interface
3263
+ const adapter = {
3264
+ data: {},
3265
+ send(data) {
3266
+ ws.send(data);
3267
+ },
3268
+ close() {
3269
+ ws.close();
3270
+ },
3271
+ get readyState() {
3272
+ return ws.readyState;
3273
+ },
3274
+ };
3275
+ wsHandlers.handleOpen(adapter);
3276
+ ws.on('message', (data) => {
3277
+ wsHandlers.handleMessage(adapter, typeof data === 'string' ? data : data.toString());
3278
+ });
3279
+ ws.on('close', () => {
3280
+ wsHandlers.handleClose(adapter);
3281
+ });
3282
+ ws.on('error', (error) => {
3283
+ wsHandlers.handleError(adapter, error);
3284
+ });
3285
+ });
3286
+ httpServer.on('upgrade', (req, socket, head) => {
3287
+ const pathname = new URL(req.url || '', `http://${options.host}`).pathname;
3288
+ if (pathname === '/ws') {
3289
+ wss.handleUpgrade(req, socket, head, (ws) => {
3290
+ wss.emit('connection', ws, req);
3291
+ });
3292
+ }
3293
+ else {
3294
+ socket.destroy();
3295
+ }
3296
+ });
3297
+ httpServer.listen(options.port, options.host, () => {
3298
+ console.log(`[stoneforge] Node server listening on http://${options.host}:${options.port}`);
3299
+ });
3300
+ });
3301
+ });
3302
+ }
3303
+ // ============================================================================
3304
+ // startQuarryServer
3305
+ // ============================================================================
3306
+ export function startQuarryServer(options = {}) {
3307
+ const quarryApp = createQuarryApp(options);
3308
+ const port = options.port ?? parseInt(process.env.PORT || '3456', 10);
3309
+ const host = options.host ?? (process.env.HOST || 'localhost');
3310
+ // Serve pre-built web UI if webRoot is provided and exists
3311
+ if (options.webRoot) {
3312
+ registerStaticMiddleware(quarryApp.app, options.webRoot);
3313
+ }
3314
+ const wsHandlers = {
3315
+ handleOpen,
3316
+ handleMessage,
3317
+ handleClose,
3318
+ handleError,
3319
+ };
3320
+ console.log(`[stoneforge] Starting server on http://${host}:${port}`);
3321
+ if (isBun) {
3322
+ startBunServer(quarryApp.app, { port, host }, wsHandlers);
3323
+ }
3324
+ else {
3325
+ startNodeServer(quarryApp.app, { port, host }, wsHandlers);
3326
+ }
3327
+ return quarryApp;
3328
+ }
3329
+ //# sourceMappingURL=index.js.map