@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.
- package/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/api/index.d.ts +8 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +8 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/quarry-api.d.ts +268 -0
- package/dist/api/quarry-api.d.ts.map +1 -0
- package/dist/api/quarry-api.js +3905 -0
- package/dist/api/quarry-api.js.map +1 -0
- package/dist/api/types.d.ts +1359 -0
- package/dist/api/types.d.ts.map +1 -0
- package/dist/api/types.js +204 -0
- package/dist/api/types.js.map +1 -0
- package/dist/bin/sf.d.ts +3 -0
- package/dist/bin/sf.d.ts.map +1 -0
- package/dist/bin/sf.js +9 -0
- package/dist/bin/sf.js.map +1 -0
- package/dist/cli/commands/admin.d.ts +11 -0
- package/dist/cli/commands/admin.d.ts.map +1 -0
- package/dist/cli/commands/admin.js +465 -0
- package/dist/cli/commands/admin.js.map +1 -0
- package/dist/cli/commands/alias.d.ts +8 -0
- package/dist/cli/commands/alias.d.ts.map +1 -0
- package/dist/cli/commands/alias.js +70 -0
- package/dist/cli/commands/alias.js.map +1 -0
- package/dist/cli/commands/channel.d.ts +13 -0
- package/dist/cli/commands/channel.d.ts.map +1 -0
- package/dist/cli/commands/channel.js +680 -0
- package/dist/cli/commands/channel.js.map +1 -0
- package/dist/cli/commands/completion.d.ts +8 -0
- package/dist/cli/commands/completion.d.ts.map +1 -0
- package/dist/cli/commands/completion.js +87 -0
- package/dist/cli/commands/completion.js.map +1 -0
- package/dist/cli/commands/config.d.ts +12 -0
- package/dist/cli/commands/config.d.ts.map +1 -0
- package/dist/cli/commands/config.js +242 -0
- package/dist/cli/commands/config.js.map +1 -0
- package/dist/cli/commands/crud.d.ts +64 -0
- package/dist/cli/commands/crud.d.ts.map +1 -0
- package/dist/cli/commands/crud.js +805 -0
- package/dist/cli/commands/crud.js.map +1 -0
- package/dist/cli/commands/dep.d.ts +16 -0
- package/dist/cli/commands/dep.d.ts.map +1 -0
- package/dist/cli/commands/dep.js +499 -0
- package/dist/cli/commands/dep.js.map +1 -0
- package/dist/cli/commands/document.d.ts +12 -0
- package/dist/cli/commands/document.d.ts.map +1 -0
- package/dist/cli/commands/document.js +1039 -0
- package/dist/cli/commands/document.js.map +1 -0
- package/dist/cli/commands/embeddings.d.ts +12 -0
- package/dist/cli/commands/embeddings.d.ts.map +1 -0
- package/dist/cli/commands/embeddings.js +273 -0
- package/dist/cli/commands/embeddings.js.map +1 -0
- package/dist/cli/commands/entity.d.ts +16 -0
- package/dist/cli/commands/entity.d.ts.map +1 -0
- package/dist/cli/commands/entity.js +522 -0
- package/dist/cli/commands/entity.js.map +1 -0
- package/dist/cli/commands/gc.d.ts +10 -0
- package/dist/cli/commands/gc.d.ts.map +1 -0
- package/dist/cli/commands/gc.js +257 -0
- package/dist/cli/commands/gc.js.map +1 -0
- package/dist/cli/commands/help.d.ts +11 -0
- package/dist/cli/commands/help.d.ts.map +1 -0
- package/dist/cli/commands/help.js +169 -0
- package/dist/cli/commands/help.js.map +1 -0
- package/dist/cli/commands/history.d.ts +9 -0
- package/dist/cli/commands/history.d.ts.map +1 -0
- package/dist/cli/commands/history.js +160 -0
- package/dist/cli/commands/history.js.map +1 -0
- package/dist/cli/commands/identity.d.ts +18 -0
- package/dist/cli/commands/identity.d.ts.map +1 -0
- package/dist/cli/commands/identity.js +698 -0
- package/dist/cli/commands/identity.js.map +1 -0
- package/dist/cli/commands/inbox.d.ts +20 -0
- package/dist/cli/commands/inbox.d.ts.map +1 -0
- package/dist/cli/commands/inbox.js +493 -0
- package/dist/cli/commands/inbox.js.map +1 -0
- package/dist/cli/commands/init.d.ts +20 -0
- package/dist/cli/commands/init.d.ts.map +1 -0
- package/dist/cli/commands/init.js +144 -0
- package/dist/cli/commands/init.js.map +1 -0
- package/dist/cli/commands/install.d.ts +9 -0
- package/dist/cli/commands/install.d.ts.map +1 -0
- package/dist/cli/commands/install.js +200 -0
- package/dist/cli/commands/install.js.map +1 -0
- package/dist/cli/commands/library.d.ts +12 -0
- package/dist/cli/commands/library.d.ts.map +1 -0
- package/dist/cli/commands/library.js +665 -0
- package/dist/cli/commands/library.js.map +1 -0
- package/dist/cli/commands/message.d.ts +11 -0
- package/dist/cli/commands/message.d.ts.map +1 -0
- package/dist/cli/commands/message.js +608 -0
- package/dist/cli/commands/message.js.map +1 -0
- package/dist/cli/commands/plan.d.ts +17 -0
- package/dist/cli/commands/plan.d.ts.map +1 -0
- package/dist/cli/commands/plan.js +698 -0
- package/dist/cli/commands/plan.js.map +1 -0
- package/dist/cli/commands/playbook.d.ts +12 -0
- package/dist/cli/commands/playbook.d.ts.map +1 -0
- package/dist/cli/commands/playbook.js +730 -0
- package/dist/cli/commands/playbook.js.map +1 -0
- package/dist/cli/commands/reset.d.ts +12 -0
- package/dist/cli/commands/reset.d.ts.map +1 -0
- package/dist/cli/commands/reset.js +306 -0
- package/dist/cli/commands/reset.js.map +1 -0
- package/dist/cli/commands/serve.d.ts +11 -0
- package/dist/cli/commands/serve.d.ts.map +1 -0
- package/dist/cli/commands/serve.js +106 -0
- package/dist/cli/commands/serve.js.map +1 -0
- package/dist/cli/commands/stats.d.ts +8 -0
- package/dist/cli/commands/stats.d.ts.map +1 -0
- package/dist/cli/commands/stats.js +82 -0
- package/dist/cli/commands/stats.js.map +1 -0
- package/dist/cli/commands/sync.d.ts +14 -0
- package/dist/cli/commands/sync.d.ts.map +1 -0
- package/dist/cli/commands/sync.js +370 -0
- package/dist/cli/commands/sync.js.map +1 -0
- package/dist/cli/commands/task.d.ts +25 -0
- package/dist/cli/commands/task.d.ts.map +1 -0
- package/dist/cli/commands/task.js +1153 -0
- package/dist/cli/commands/task.js.map +1 -0
- package/dist/cli/commands/team.d.ts +13 -0
- package/dist/cli/commands/team.d.ts.map +1 -0
- package/dist/cli/commands/team.js +471 -0
- package/dist/cli/commands/team.js.map +1 -0
- package/dist/cli/commands/workflow.d.ts +16 -0
- package/dist/cli/commands/workflow.d.ts.map +1 -0
- package/dist/cli/commands/workflow.js +753 -0
- package/dist/cli/commands/workflow.js.map +1 -0
- package/dist/cli/completion.d.ts +28 -0
- package/dist/cli/completion.d.ts.map +1 -0
- package/dist/cli/completion.js +295 -0
- package/dist/cli/completion.js.map +1 -0
- package/dist/cli/db.d.ts +38 -0
- package/dist/cli/db.d.ts.map +1 -0
- package/dist/cli/db.js +90 -0
- package/dist/cli/db.js.map +1 -0
- package/dist/cli/formatter.d.ts +87 -0
- package/dist/cli/formatter.d.ts.map +1 -0
- package/dist/cli/formatter.js +464 -0
- package/dist/cli/formatter.js.map +1 -0
- package/dist/cli/index.d.ts +33 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +38 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/parser.d.ts +45 -0
- package/dist/cli/parser.d.ts.map +1 -0
- package/dist/cli/parser.js +256 -0
- package/dist/cli/parser.js.map +1 -0
- package/dist/cli/plugin-loader.d.ts +39 -0
- package/dist/cli/plugin-loader.d.ts.map +1 -0
- package/dist/cli/plugin-loader.js +165 -0
- package/dist/cli/plugin-loader.js.map +1 -0
- package/dist/cli/plugin-registry.d.ts +50 -0
- package/dist/cli/plugin-registry.d.ts.map +1 -0
- package/dist/cli/plugin-registry.js +206 -0
- package/dist/cli/plugin-registry.js.map +1 -0
- package/dist/cli/plugin-types.d.ts +106 -0
- package/dist/cli/plugin-types.d.ts.map +1 -0
- package/dist/cli/plugin-types.js +103 -0
- package/dist/cli/plugin-types.js.map +1 -0
- package/dist/cli/runner.d.ts +35 -0
- package/dist/cli/runner.d.ts.map +1 -0
- package/dist/cli/runner.js +340 -0
- package/dist/cli/runner.js.map +1 -0
- package/dist/cli/suggest.d.ts +15 -0
- package/dist/cli/suggest.d.ts.map +1 -0
- package/dist/cli/suggest.js +49 -0
- package/dist/cli/suggest.js.map +1 -0
- package/dist/cli/types.d.ts +138 -0
- package/dist/cli/types.d.ts.map +1 -0
- package/dist/cli/types.js +63 -0
- package/dist/cli/types.js.map +1 -0
- package/dist/config/config.d.ts +86 -0
- package/dist/config/config.d.ts.map +1 -0
- package/dist/config/config.js +348 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/defaults.d.ts +66 -0
- package/dist/config/defaults.d.ts.map +1 -0
- package/dist/config/defaults.js +114 -0
- package/dist/config/defaults.js.map +1 -0
- package/dist/config/duration.d.ts +75 -0
- package/dist/config/duration.d.ts.map +1 -0
- package/dist/config/duration.js +190 -0
- package/dist/config/duration.js.map +1 -0
- package/dist/config/env.d.ts +67 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +207 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/file.d.ts +97 -0
- package/dist/config/file.d.ts.map +1 -0
- package/dist/config/file.js +365 -0
- package/dist/config/file.js.map +1 -0
- package/dist/config/index.d.ts +35 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +41 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/merge.d.ts +53 -0
- package/dist/config/merge.d.ts.map +1 -0
- package/dist/config/merge.js +226 -0
- package/dist/config/merge.js.map +1 -0
- package/dist/config/types.d.ts +257 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +72 -0
- package/dist/config/types.js.map +1 -0
- package/dist/config/validation.d.ts +55 -0
- package/dist/config/validation.d.ts.map +1 -0
- package/dist/config/validation.js +251 -0
- package/dist/config/validation.js.map +1 -0
- package/dist/http/index.d.ts +8 -0
- package/dist/http/index.d.ts.map +1 -0
- package/dist/http/index.js +12 -0
- package/dist/http/index.js.map +1 -0
- package/dist/http/sync-handlers.d.ts +162 -0
- package/dist/http/sync-handlers.d.ts.map +1 -0
- package/dist/http/sync-handlers.js +271 -0
- package/dist/http/sync-handlers.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +69 -0
- package/dist/index.js.map +1 -0
- package/dist/server/index.d.ts +34 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +3329 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/static.d.ts +18 -0
- package/dist/server/static.d.ts.map +1 -0
- package/dist/server/static.js +71 -0
- package/dist/server/static.js.map +1 -0
- package/dist/server/ws/broadcaster.d.ts +8 -0
- package/dist/server/ws/broadcaster.d.ts.map +1 -0
- package/dist/server/ws/broadcaster.js +7 -0
- package/dist/server/ws/broadcaster.js.map +1 -0
- package/dist/server/ws/handler.d.ts +55 -0
- package/dist/server/ws/handler.d.ts.map +1 -0
- package/dist/server/ws/handler.js +160 -0
- package/dist/server/ws/handler.js.map +1 -0
- package/dist/services/blocked-cache.d.ts +297 -0
- package/dist/services/blocked-cache.d.ts.map +1 -0
- package/dist/services/blocked-cache.js +755 -0
- package/dist/services/blocked-cache.js.map +1 -0
- package/dist/services/dependency.d.ts +205 -0
- package/dist/services/dependency.d.ts.map +1 -0
- package/dist/services/dependency.js +566 -0
- package/dist/services/dependency.js.map +1 -0
- package/dist/services/embeddings/fusion.d.ts +33 -0
- package/dist/services/embeddings/fusion.d.ts.map +1 -0
- package/dist/services/embeddings/fusion.js +34 -0
- package/dist/services/embeddings/fusion.js.map +1 -0
- package/dist/services/embeddings/index.d.ts +12 -0
- package/dist/services/embeddings/index.d.ts.map +1 -0
- package/dist/services/embeddings/index.js +10 -0
- package/dist/services/embeddings/index.js.map +1 -0
- package/dist/services/embeddings/local-provider.d.ts +31 -0
- package/dist/services/embeddings/local-provider.d.ts.map +1 -0
- package/dist/services/embeddings/local-provider.js +80 -0
- package/dist/services/embeddings/local-provider.js.map +1 -0
- package/dist/services/embeddings/service.d.ts +76 -0
- package/dist/services/embeddings/service.d.ts.map +1 -0
- package/dist/services/embeddings/service.js +153 -0
- package/dist/services/embeddings/service.js.map +1 -0
- package/dist/services/embeddings/types.d.ts +70 -0
- package/dist/services/embeddings/types.d.ts.map +1 -0
- package/dist/services/embeddings/types.js +8 -0
- package/dist/services/embeddings/types.js.map +1 -0
- package/dist/services/id-length-cache.d.ts +156 -0
- package/dist/services/id-length-cache.d.ts.map +1 -0
- package/dist/services/id-length-cache.js +197 -0
- package/dist/services/id-length-cache.js.map +1 -0
- package/dist/services/inbox.d.ts +147 -0
- package/dist/services/inbox.d.ts.map +1 -0
- package/dist/services/inbox.js +428 -0
- package/dist/services/inbox.js.map +1 -0
- package/dist/services/index.d.ts +10 -0
- package/dist/services/index.d.ts.map +1 -0
- package/dist/services/index.js +10 -0
- package/dist/services/index.js.map +1 -0
- package/dist/services/priority-service.d.ts +145 -0
- package/dist/services/priority-service.d.ts.map +1 -0
- package/dist/services/priority-service.js +272 -0
- package/dist/services/priority-service.js.map +1 -0
- package/dist/services/search-utils.d.ts +47 -0
- package/dist/services/search-utils.d.ts.map +1 -0
- package/dist/services/search-utils.js +83 -0
- package/dist/services/search-utils.js.map +1 -0
- package/dist/sync/hash.d.ts +48 -0
- package/dist/sync/hash.d.ts.map +1 -0
- package/dist/sync/hash.js +136 -0
- package/dist/sync/hash.js.map +1 -0
- package/dist/sync/index.d.ts +11 -0
- package/dist/sync/index.d.ts.map +1 -0
- package/dist/sync/index.js +16 -0
- package/dist/sync/index.js.map +1 -0
- package/dist/sync/merge.d.ts +80 -0
- package/dist/sync/merge.d.ts.map +1 -0
- package/dist/sync/merge.js +310 -0
- package/dist/sync/merge.js.map +1 -0
- package/dist/sync/serialization.d.ts +132 -0
- package/dist/sync/serialization.d.ts.map +1 -0
- package/dist/sync/serialization.js +306 -0
- package/dist/sync/serialization.js.map +1 -0
- package/dist/sync/service.d.ts +102 -0
- package/dist/sync/service.d.ts.map +1 -0
- package/dist/sync/service.js +493 -0
- package/dist/sync/service.js.map +1 -0
- package/dist/sync/types.d.ts +275 -0
- package/dist/sync/types.d.ts.map +1 -0
- package/dist/sync/types.js +76 -0
- package/dist/sync/types.js.map +1 -0
- package/dist/systems/identity.d.ts +479 -0
- package/dist/systems/identity.d.ts.map +1 -0
- package/dist/systems/identity.js +817 -0
- package/dist/systems/identity.js.map +1 -0
- package/dist/systems/index.d.ts +8 -0
- package/dist/systems/index.d.ts.map +1 -0
- package/dist/systems/index.js +29 -0
- package/dist/systems/index.js.map +1 -0
- package/package.json +121 -0
- package/web/assets/charts-vendor-D1YcbGux.js +55 -0
- package/web/assets/dnd-vendor-DmxE-_ZH.js +5 -0
- package/web/assets/editor-vendor-BxraAWts.js +279 -0
- package/web/assets/index-B77vv208.js +341 -0
- package/web/assets/index-CF_XnVLh.css +1 -0
- package/web/assets/router-vendor-BCKpRBrB.js +41 -0
- package/web/assets/ui-vendor-DUahGnbT.js +45 -0
- package/web/assets/utils-vendor-CfYKiENT.js +813 -0
- package/web/favicon.ico +0 -0
- package/web/index.html +23 -0
- 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 )
|
|
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
|