@pixelbyte-software/pixcode 1.42.5 → 1.43.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/assets/index-B-_FofJ_.css +32 -0
  2. package/dist/assets/{index-nefOyhzb.js → index-CDKI7Ucy.js} +140 -140
  3. package/dist/index.html +2 -2
  4. package/dist-server/server/index.js +3 -0
  5. package/dist-server/server/index.js.map +1 -1
  6. package/dist-server/server/modules/orchestration/index.js +1 -0
  7. package/dist-server/server/modules/orchestration/index.js.map +1 -1
  8. package/dist-server/server/modules/orchestration/workflows/approval-queue.js +72 -0
  9. package/dist-server/server/modules/orchestration/workflows/approval-queue.js.map +1 -0
  10. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +25 -0
  11. package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
  12. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js +87 -0
  13. package/dist-server/server/modules/orchestration/workflows/workflow.routes.js.map +1 -1
  14. package/dist-server/server/routes/public-api.js +7 -1
  15. package/dist-server/server/routes/public-api.js.map +1 -1
  16. package/dist-server/server/routes/remote.js +18 -0
  17. package/dist-server/server/routes/remote.js.map +1 -1
  18. package/dist-server/server/routes/webhooks.js +53 -0
  19. package/dist-server/server/routes/webhooks.js.map +1 -0
  20. package/dist-server/server/services/control-room.js +89 -0
  21. package/dist-server/server/services/control-room.js.map +1 -0
  22. package/dist-server/server/services/public-api-manifest.js +96 -0
  23. package/dist-server/server/services/public-api-manifest.js.map +1 -1
  24. package/dist-server/server/services/telegram/control-center.js +110 -0
  25. package/dist-server/server/services/telegram/control-center.js.map +1 -1
  26. package/dist-server/server/services/telegram/translations.js +24 -2
  27. package/dist-server/server/services/telegram/translations.js.map +1 -1
  28. package/dist-server/server/services/webhooks.js +198 -0
  29. package/dist-server/server/services/webhooks.js.map +1 -0
  30. package/package.json +1 -1
  31. package/scripts/smoke/v143-remote-control.mjs +76 -0
  32. package/server/index.js +4 -0
  33. package/server/modules/orchestration/index.ts +4 -0
  34. package/server/modules/orchestration/workflows/approval-queue.ts +106 -0
  35. package/server/modules/orchestration/workflows/workflow-runner.ts +25 -0
  36. package/server/modules/orchestration/workflows/workflow.routes.ts +95 -0
  37. package/server/routes/public-api.js +14 -1
  38. package/server/routes/remote.js +22 -0
  39. package/server/routes/webhooks.js +63 -0
  40. package/server/services/control-room.js +102 -0
  41. package/server/services/public-api-manifest.js +98 -0
  42. package/server/services/telegram/control-center.js +113 -0
  43. package/server/services/telegram/translations.js +24 -2
  44. package/server/services/webhooks.js +216 -0
  45. package/dist/assets/index-CHa1760s.css +0 -32
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+
3
+ import assert from 'node:assert/strict';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+
7
+ const root = process.cwd();
8
+
9
+ function read(relativePath) {
10
+ return fs.readFileSync(path.join(root, relativePath), 'utf8');
11
+ }
12
+
13
+ const telegram = read('server/services/telegram/control-center.js');
14
+ assert.match(telegram, /\/approvals/, 'Telegram control should expose an approvals command.');
15
+ assert.match(telegram, /showApprovalQueue/, 'Telegram menu should render pending approval decisions.');
16
+ assert.match(telegram, /approval_decide/, 'Telegram callbacks should allow approval decisions.');
17
+ assert.match(telegram, /showControlRoom/, 'Telegram menu should expose the multi-project control room.');
18
+ assert.match(telegram, /showWebhookMenu/, 'Telegram menu should expose webhook status.');
19
+
20
+ const workflowRoutes = read('server/modules/orchestration/workflows/workflow.routes.ts');
21
+ assert.match(workflowRoutes, /\/workflows\/approvals/, 'Orchestration should expose a global approval queue.');
22
+ assert.match(workflowRoutes, /resolvePermissionApproval/, 'Global approval route should resolve permission approvals.');
23
+ assert.match(workflowRoutes, /dispatchWebhookEvent/, 'Workflow routes should dispatch webhook events for remote automation.');
24
+
25
+ const approvalQueue = read('server/modules/orchestration/workflows/approval-queue.ts');
26
+ assert.match(approvalQueue, /listPendingApprovals/, 'Approval queue should list pending approvals across runs.');
27
+ assert.match(approvalQueue, /resolvePermissionApproval/, 'Approval queue should resolve approval requests centrally.');
28
+ assert.match(approvalQueue, /source: 'ui' \| 'telegram' \| 'api'/, 'Approval queue should preserve the decision source.');
29
+
30
+ const webhooks = read('server/services/webhooks.js');
31
+ assert.match(webhooks, /PIXCODE_WEBHOOK_EVENT_TYPES/, 'Webhook service should declare supported event taxonomy.');
32
+ assert.match(webhooks, /run\.completed/, 'Webhook taxonomy should include run.completed.');
33
+ assert.match(webhooks, /approval\.needed/, 'Webhook taxonomy should include approval.needed.');
34
+ assert.match(webhooks, /deliverWebhookEvent/, 'Webhook service should deliver signed outbound events.');
35
+
36
+ const webhookRoutes = read('server/routes/webhooks.js');
37
+ assert.match(webhookRoutes, /router\.get\('\/'/, 'Webhook routes should list configured webhooks.');
38
+ assert.match(webhookRoutes, /router\.post\('\/test'/, 'Webhook routes should support test delivery.');
39
+
40
+ const remote = read('server/routes/remote.js');
41
+ assert.match(remote, /\/control-room/, 'Remote API should expose the control room snapshot.');
42
+ assert.match(remote, /\/console-layout/, 'Remote API should expose mobile console layout metadata.');
43
+
44
+ const controlRoom = read('server/services/control-room.js');
45
+ assert.match(controlRoom, /buildControlRoomSnapshot/, 'Control room should build a multi-project snapshot.');
46
+ assert.match(controlRoom, /maxProjects = 4/, 'Control room should cap the live overview at four projects.');
47
+ assert.match(controlRoom, /mobileFirst/, 'Control room should include mobile-first console metadata.');
48
+
49
+ const publicApi = read('server/routes/public-api.js');
50
+ assert.match(publicApi, /\/sdk\/typescript/, 'Public API should expose a TypeScript SDK starter.');
51
+ assert.match(publicApi, /\/cookbook/, 'Public API should expose a curl cookbook.');
52
+
53
+ const manifest = read('server/services/public-api-manifest.js');
54
+ assert.match(manifest, /buildTypeScriptSdkStarter/, 'Public API manifest should generate typed TypeScript SDK examples.');
55
+ assert.match(manifest, /webhooks/, 'Public API manifest should document webhook endpoints.');
56
+
57
+ const appTypes = read('src/types/app.ts');
58
+ assert.match(appTypes, /'remote'/, 'Frontend tabs should include the remote console.');
59
+
60
+ const mainContent = read('src/components/main-content/view/MainContent.tsx');
61
+ assert.match(mainContent, /RemoteConsole/, 'Main content should render the remote console tab.');
62
+
63
+ const tabSwitcher = read('src/components/main-content/view/subcomponents/MainContentTabSwitcher.tsx');
64
+ assert.match(tabSwitcher, /tabs\.remote/, 'Tab switcher should expose the remote console tab.');
65
+
66
+ const remoteConsole = read('src/components/remote-console/RemoteConsole.tsx');
67
+ assert.match(remoteConsole, /control-room/, 'Remote console should load control-room snapshots.');
68
+ assert.match(remoteConsole, /approval queue/i, 'Remote console should show the approval queue.');
69
+ assert.match(remoteConsole, /webhook/i, 'Remote console should show webhook health.');
70
+
71
+ const docs = read('docs/self-hosted-agent-control-room.md');
72
+ assert.match(docs, /Remote Control/, 'Docs should explain remote control workflows.');
73
+ assert.match(docs, /Webhook/, 'Docs should explain webhook automation.');
74
+ assert.match(docs, /Telegram/, 'Docs should explain Telegram control.');
75
+
76
+ console.log('v1.43 remote control smoke passed');
package/server/index.js CHANGED
@@ -78,6 +78,7 @@ import messagesRoutes from './routes/messages.js';
78
78
  import diagnosticsRoutes from './routes/diagnostics.js';
79
79
  import remoteRoutes from './routes/remote.js';
80
80
  import publicApiRoutes from './routes/public-api.js';
81
+ import webhooksRoutes from './routes/webhooks.js';
81
82
  import liveViewRoutes, { createLiveViewPublicRouter } from './routes/live-view.js';
82
83
  import providerRoutes from './modules/providers/provider.routes.js';
83
84
  import {
@@ -406,6 +407,9 @@ app.use('/api/remote', authenticateToken, remoteRoutes);
406
407
  // Public automation manifest (protected so private host details only go to signed-in clients)
407
408
  app.use('/api/public', authenticateToken, publicApiRoutes);
408
409
 
410
+ // Outbound webhook automation (protected)
411
+ app.use('/api/webhooks', authenticateToken, webhooksRoutes);
412
+
409
413
  // Project Live View (protected control API + public share proxy)
410
414
  app.use('/api/live-view', authenticateToken, liveViewRoutes);
411
415
 
@@ -31,6 +31,10 @@ export {
31
31
  export { createOrchestrationTaskRouter } from './tasks/orchestration-task.routes.js';
32
32
  export { orchestrationTaskService } from './tasks/orchestration-task.service.js';
33
33
  export { createWorkflowRouter } from './workflows/workflow.routes.js';
34
+ export {
35
+ listPendingApprovals,
36
+ resolvePermissionApproval,
37
+ } from './workflows/approval-queue.js';
34
38
  export {
35
39
  PIXCODE_WORKFLOW_TEMPLATE_PROTOCOL,
36
40
  applyWorkflowTemplateToMetadata,
@@ -0,0 +1,106 @@
1
+ import type { WorkflowRun } from '@/modules/orchestration/workflows/workflow.types.js';
2
+ import { workflowStore } from '@/modules/orchestration/workflows/workflow-store.js';
3
+
4
+ export type ApprovalDecisionSource = 'ui' | 'telegram' | 'api';
5
+
6
+ export type ApprovalQueueItem = Record<string, unknown> & {
7
+ id: string;
8
+ runId: string;
9
+ workflowId: string;
10
+ status: string;
11
+ requestedAt?: number;
12
+ };
13
+
14
+ function readRunApprovals(run: WorkflowRun): Array<Record<string, unknown>> {
15
+ const approvals = run.metadata?.pendingPermissionApprovals;
16
+ return Array.isArray(approvals)
17
+ ? approvals.filter((approval): approval is Record<string, unknown> => Boolean(approval && typeof approval === 'object'))
18
+ : [];
19
+ }
20
+
21
+ function normalizeApproval(run: WorkflowRun, approval: Record<string, unknown>): ApprovalQueueItem | null {
22
+ const id = typeof approval.id === 'string' && approval.id.trim() ? approval.id : null;
23
+ if (!id) return null;
24
+
25
+ return {
26
+ ...approval,
27
+ id,
28
+ runId: run.id,
29
+ workflowId: run.workflowId,
30
+ status: typeof approval.status === 'string' ? approval.status : 'pending',
31
+ requestedAt: typeof approval.requestedAt === 'number' ? approval.requestedAt : run.startedAt,
32
+ };
33
+ }
34
+
35
+ export function listPendingApprovals(options: {
36
+ projectId?: string;
37
+ includeResolved?: boolean;
38
+ } = {}): ApprovalQueueItem[] {
39
+ const items: ApprovalQueueItem[] = [];
40
+ for (const run of workflowStore.listRuns()) {
41
+ if (options.projectId && run.metadata?.projectId !== options.projectId) continue;
42
+ for (const approval of readRunApprovals(run)) {
43
+ const item = normalizeApproval(run, approval);
44
+ if (!item) continue;
45
+ if (!options.includeResolved && item.status !== 'pending') continue;
46
+ items.push(item);
47
+ }
48
+ }
49
+
50
+ return items.sort((a, b) => Number(b.requestedAt ?? 0) - Number(a.requestedAt ?? 0));
51
+ }
52
+
53
+ export function resolvePermissionApproval({
54
+ approvalId,
55
+ allow,
56
+ source = 'api',
57
+ resolvedBy,
58
+ message,
59
+ }: {
60
+ approvalId: string;
61
+ allow: boolean;
62
+ source?: ApprovalDecisionSource; // source: 'ui' | 'telegram' | 'api'
63
+ resolvedBy?: string | number | null;
64
+ message?: string;
65
+ }): {
66
+ runId: string;
67
+ pendingApprovals: ApprovalQueueItem[];
68
+ approvalHistory: ApprovalQueueItem[];
69
+ } | null {
70
+ for (const run of workflowStore.listRuns()) {
71
+ const approvals = readRunApprovals(run);
72
+ let changed = false;
73
+ const nextApprovals = approvals.map((approval) => {
74
+ if (approval.id !== approvalId) return approval;
75
+ changed = true;
76
+ return {
77
+ ...approval,
78
+ status: allow ? 'allowed' : 'denied',
79
+ resolvedAt: Date.now(),
80
+ resolvedBy: resolvedBy ?? null,
81
+ decisionSource: source,
82
+ resolutionMessage: typeof message === 'string' && message.trim() ? message.trim() : undefined,
83
+ };
84
+ });
85
+
86
+ if (!changed) continue;
87
+
88
+ run.metadata = {
89
+ ...run.metadata,
90
+ pendingPermissionApprovals: nextApprovals,
91
+ };
92
+ workflowStore.setRun(run);
93
+
94
+ const queueItems = nextApprovals
95
+ .map((approval) => normalizeApproval(run, approval))
96
+ .filter((approval): approval is ApprovalQueueItem => Boolean(approval));
97
+
98
+ return {
99
+ runId: run.id,
100
+ pendingApprovals: queueItems.filter((approval) => approval.status === 'pending'),
101
+ approvalHistory: queueItems.filter((approval) => approval.status !== 'pending'),
102
+ };
103
+ }
104
+
105
+ return null;
106
+ }
@@ -49,6 +49,8 @@ import {
49
49
  notifyRunStopped,
50
50
  notifyUserIfEnabled,
51
51
  } from '@/services/notification-orchestrator.js';
52
+ // @ts-ignore — plain-JS service
53
+ import { dispatchWebhookEvent } from '@/services/webhooks.js';
52
54
 
53
55
  const TERMINAL = new Set(['completed', 'failed', 'canceled']);
54
56
  const SKIPPED = 'skipped';
@@ -1655,6 +1657,20 @@ class WorkflowRunner {
1655
1657
  workflowStore.setRun(run);
1656
1658
  orchestrationTaskService.updateFromWorkflowRun(run);
1657
1659
  notifyWorkflowRunFinished(run);
1660
+ const webhookRunStatus = String(run.status);
1661
+ dispatchWebhookEvent({
1662
+ type: webhookRunStatus === 'completed'
1663
+ ? 'run.completed'
1664
+ : webhookRunStatus === 'canceled'
1665
+ ? 'run.canceled'
1666
+ : 'run.failed',
1667
+ payload: {
1668
+ runId: run.id,
1669
+ workflowId: run.workflowId,
1670
+ status: webhookRunStatus,
1671
+ error: readString(run.metadata?.error),
1672
+ },
1673
+ });
1658
1674
  this.cancelingRuns.delete(run.id);
1659
1675
  }
1660
1676
  }
@@ -1687,6 +1703,15 @@ class WorkflowRunner {
1687
1703
 
1688
1704
  if (decision.approvalRequest) {
1689
1705
  notifyPermissionApprovalRequested(run, decision);
1706
+ dispatchWebhookEvent({
1707
+ type: 'approval.needed',
1708
+ payload: {
1709
+ runId: run.id,
1710
+ workflowId: run.workflowId,
1711
+ approvalId: decision.approvalRequest.id,
1712
+ capabilities: decision.capabilities,
1713
+ },
1714
+ });
1690
1715
  }
1691
1716
  }
1692
1717
 
@@ -14,6 +14,11 @@ import {
14
14
  import { workflowStore } from '@/modules/orchestration/workflows/workflow-store.js';
15
15
  import { buildWorkflowTrace } from '@/modules/orchestration/workflows/workflow-trace.js';
16
16
  import { findPixcodeAppRoot } from '@/modules/orchestration/workflows/workspace-target.js';
17
+ import {
18
+ listPendingApprovals,
19
+ resolvePermissionApproval,
20
+ type ApprovalDecisionSource,
21
+ } from '@/modules/orchestration/workflows/approval-queue.js';
17
22
  import {
18
23
  DEFAULT_PERMISSION_POLICY,
19
24
  PERMISSION_CAPABILITIES,
@@ -22,6 +27,7 @@ import {
22
27
  evaluatePermissionRequest,
23
28
  normalizePermissionPolicy,
24
29
  } from '@/modules/orchestration/security/permission-policy.js';
30
+ import { dispatchWebhookEvent } from '@/services/webhooks.js';
25
31
 
26
32
  const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'canceled']);
27
33
 
@@ -254,6 +260,55 @@ export function createWorkflowRouter(): Router {
254
260
  });
255
261
  });
256
262
 
263
+ router.get('/workflows/approvals', (req, res) => {
264
+ res.json({
265
+ pendingApprovals: listPendingApprovals({
266
+ projectId: readOptionalString(req.query.projectId),
267
+ includeResolved: readBooleanFlag(req.query.includeResolved),
268
+ }),
269
+ });
270
+ });
271
+
272
+ router.post('/workflows/approvals/:approvalId', (req, res) => {
273
+ const allow = req.body?.allow === true;
274
+ const deny = req.body?.allow === false;
275
+ if (!allow && !deny) {
276
+ res.status(400).json({
277
+ error: {
278
+ code: 'PERMISSION_DECISION_REQUIRED',
279
+ message: 'Approval queue decisions require allow=true or allow=false.',
280
+ },
281
+ });
282
+ return;
283
+ }
284
+
285
+ const source = ['ui', 'telegram', 'api'].includes(req.body?.source)
286
+ ? req.body.source as ApprovalDecisionSource
287
+ : 'api';
288
+ const result = resolvePermissionApproval({
289
+ approvalId: req.params.approvalId,
290
+ allow,
291
+ source,
292
+ resolvedBy: readRequestUserId(req),
293
+ message: readOptionalString(req.body?.message),
294
+ });
295
+ if (!result) {
296
+ res.status(404).json({ error: { code: 'APPROVAL_NOT_FOUND', message: req.params.approvalId } });
297
+ return;
298
+ }
299
+
300
+ dispatchWebhookEvent({
301
+ type: 'approval.resolved',
302
+ payload: {
303
+ approvalId: req.params.approvalId,
304
+ allow,
305
+ source,
306
+ runId: result.runId,
307
+ },
308
+ });
309
+ res.json(result);
310
+ });
311
+
257
312
  router.post('/workflows/runs/:runId/permission-approvals/:requestId', (req, res) => {
258
313
  const run = workflowStore.getRun(req.params.runId);
259
314
  if (!run) {
@@ -285,6 +340,15 @@ export function createWorkflowRouter(): Router {
285
340
  }
286
341
 
287
342
  workflowStore.setRun(run);
343
+ dispatchWebhookEvent({
344
+ type: 'approval.resolved',
345
+ payload: {
346
+ approvalId: req.params.requestId,
347
+ allow,
348
+ source: 'ui',
349
+ runId: run.id,
350
+ },
351
+ });
288
352
  res.json({
289
353
  runId: run.id,
290
354
  pendingApprovals: readRunArray(run, 'pendingPermissionApprovals')
@@ -359,6 +423,14 @@ export function createWorkflowRouter(): Router {
359
423
  },
360
424
  },
361
425
  );
426
+ dispatchWebhookEvent({
427
+ type: 'run.started',
428
+ payload: {
429
+ runId: replayRun.id,
430
+ workflowId: replayRun.workflowId,
431
+ replayOf: run.id,
432
+ },
433
+ });
362
434
  res.status(202).json({
363
435
  run: replayRun,
364
436
  replayPlan,
@@ -424,6 +496,13 @@ export function createWorkflowRouter(): Router {
424
496
  res.status(404).json({ error: { code: 'RUN_NOT_FOUND', message: req.params.runId } });
425
497
  return;
426
498
  }
499
+ dispatchWebhookEvent({
500
+ type: 'run.canceled',
501
+ payload: {
502
+ runId: run.id,
503
+ workflowId: run.workflowId,
504
+ },
505
+ });
427
506
  res.json(run);
428
507
  });
429
508
 
@@ -443,6 +522,14 @@ export function createWorkflowRouter(): Router {
443
522
  workflowName: workflow.name,
444
523
  },
445
524
  );
525
+ dispatchWebhookEvent({
526
+ type: 'run.started',
527
+ payload: {
528
+ runId: run.id,
529
+ workflowId: run.workflowId,
530
+ workflowName: workflow.name,
531
+ },
532
+ });
446
533
  res.status(202).json(run);
447
534
  } catch (error) {
448
535
  res.status(400).json({
@@ -476,6 +563,14 @@ export function createWorkflowRouter(): Router {
476
563
  workflowName: workflow.name,
477
564
  },
478
565
  );
566
+ dispatchWebhookEvent({
567
+ type: 'run.started',
568
+ payload: {
569
+ runId: run.id,
570
+ workflowId: run.workflowId,
571
+ templateId: template.id,
572
+ },
573
+ });
479
574
  res.status(202).json(run);
480
575
  } catch (error) {
481
576
  res.status(400).json({
@@ -1,6 +1,11 @@
1
1
  import express from 'express';
2
2
 
3
- import { buildOpenApiFragment, buildPublicApiManifest } from '../services/public-api-manifest.js';
3
+ import {
4
+ buildCurlCookbook,
5
+ buildOpenApiFragment,
6
+ buildPublicApiManifest,
7
+ buildTypeScriptSdkStarter,
8
+ } from '../services/public-api-manifest.js';
4
9
 
5
10
  const router = express.Router();
6
11
 
@@ -18,4 +23,12 @@ router.get('/openapi', (req, res) => {
18
23
  res.json(buildOpenApiFragment({ baseUrl: requestBaseUrl(req) }));
19
24
  });
20
25
 
26
+ router.get('/sdk/typescript', (req, res) => {
27
+ res.type('text/typescript').send(buildTypeScriptSdkStarter({ baseUrl: requestBaseUrl(req) }));
28
+ });
29
+
30
+ router.get('/cookbook', (req, res) => {
31
+ res.json(buildCurlCookbook({ baseUrl: requestBaseUrl(req) }));
32
+ });
33
+
21
34
  export default router;
@@ -5,6 +5,10 @@ import {
5
5
  getPublicRemoteConnectionConfig,
6
6
  saveRemoteConnectionConfig,
7
7
  } from '../services/remote-connection.js';
8
+ import {
9
+ buildControlRoomSnapshot,
10
+ buildMobileConsoleLayout,
11
+ } from '../services/control-room.js';
8
12
 
9
13
  const router = express.Router();
10
14
 
@@ -30,4 +34,22 @@ router.post('/check', async (req, res) => {
30
34
  }
31
35
  });
32
36
 
37
+ router.get('/control-room', async (_req, res) => {
38
+ try {
39
+ res.json({
40
+ success: true,
41
+ controlRoom: await buildControlRoomSnapshot(),
42
+ });
43
+ } catch (error) {
44
+ res.status(500).json({ success: false, error: error.message });
45
+ }
46
+ });
47
+
48
+ router.get('/console-layout', (_req, res) => {
49
+ res.json({
50
+ success: true,
51
+ layout: buildMobileConsoleLayout(),
52
+ });
53
+ });
54
+
33
55
  export default router;
@@ -0,0 +1,63 @@
1
+ import express from 'express';
2
+
3
+ import {
4
+ PIXCODE_WEBHOOK_EVENT_TYPES,
5
+ deleteWebhook,
6
+ deliverWebhookEvent,
7
+ listWebhooks,
8
+ upsertWebhook,
9
+ } from '../services/webhooks.js';
10
+
11
+ const router = express.Router();
12
+
13
+ router.get('/', (_req, res) => {
14
+ res.json({
15
+ success: true,
16
+ eventTypes: PIXCODE_WEBHOOK_EVENT_TYPES,
17
+ webhooks: listWebhooks(),
18
+ });
19
+ });
20
+
21
+ router.post('/', (req, res) => {
22
+ try {
23
+ const webhook = upsertWebhook(req.body || {});
24
+ res.status(201).json({ success: true, webhook });
25
+ } catch (error) {
26
+ res.status(400).json({ success: false, error: error.message });
27
+ }
28
+ });
29
+
30
+ router.patch('/:id', (req, res) => {
31
+ try {
32
+ const webhook = upsertWebhook({ ...(req.body || {}), id: req.params.id });
33
+ res.json({ success: true, webhook });
34
+ } catch (error) {
35
+ res.status(400).json({ success: false, error: error.message });
36
+ }
37
+ });
38
+
39
+ router.delete('/:id', (req, res) => {
40
+ if (!deleteWebhook(req.params.id)) {
41
+ res.status(404).json({ success: false, error: 'Webhook not found.' });
42
+ return;
43
+ }
44
+ res.json({ success: true });
45
+ });
46
+
47
+ router.post('/test', async (req, res) => {
48
+ try {
49
+ const result = await deliverWebhookEvent({
50
+ type: req.body?.type || 'run.completed',
51
+ payload: {
52
+ test: true,
53
+ message: 'Pixcode webhook test delivery',
54
+ sentBy: req.user?.id ?? null,
55
+ },
56
+ });
57
+ res.json({ success: true, result });
58
+ } catch (error) {
59
+ res.status(500).json({ success: false, error: error.message });
60
+ }
61
+ });
62
+
63
+ export default router;
@@ -0,0 +1,102 @@
1
+ import { listPendingApprovals, workflowStore } from '../modules/orchestration/index.js';
2
+ import { getProjects } from '../projects.js';
3
+
4
+ import { listWebhooks } from './webhooks.js';
5
+
6
+ const TERMINAL_RUN_STATES = new Set(['completed', 'failed', 'canceled']);
7
+
8
+ function projectPath(project) {
9
+ return project.fullPath || project.path || '';
10
+ }
11
+
12
+ function runBelongsToProject(run, project) {
13
+ const projectId = run.metadata?.projectId;
14
+ const path = run.metadata?.projectPath;
15
+ return projectId === project.name || path === projectPath(project);
16
+ }
17
+
18
+ function countSessions(project) {
19
+ return [
20
+ project.sessions,
21
+ project.codexSessions,
22
+ project.cursorSessions,
23
+ project.geminiSessions,
24
+ project.qwenSessions,
25
+ project.opencodeSessions,
26
+ ].reduce((total, sessions) => total + (Array.isArray(sessions) ? sessions.length : 0), 0);
27
+ }
28
+
29
+ function summarizeProject(project, runs, approvals) {
30
+ const projectRuns = runs.filter((run) => runBelongsToProject(run, project));
31
+ const runningRuns = projectRuns.filter((run) => !TERMINAL_RUN_STATES.has(run.status));
32
+ const failedRuns = projectRuns.filter((run) => run.status === 'failed');
33
+ const projectApprovals = approvals.filter((approval) => (
34
+ approval.runId && projectRuns.some((run) => run.id === approval.runId)
35
+ ));
36
+
37
+ return {
38
+ id: project.name,
39
+ name: project.displayName || project.name,
40
+ path: projectPath(project),
41
+ sessionCount: countSessions(project),
42
+ activeRunCount: runningRuns.length,
43
+ failedRunCount: failedRuns.length,
44
+ pendingApprovalCount: projectApprovals.length,
45
+ latestRuns: projectRuns.slice(0, 4).map((run) => ({
46
+ id: run.id,
47
+ workflowId: run.workflowId,
48
+ status: run.status,
49
+ startedAt: run.startedAt,
50
+ finishedAt: run.finishedAt,
51
+ })),
52
+ };
53
+ }
54
+
55
+ export async function buildControlRoomSnapshot({ maxProjects = 4 } = {}) {
56
+ const projects = await getProjects();
57
+ const runs = workflowStore.listRuns();
58
+ const pendingApprovals = listPendingApprovals();
59
+ const webhooks = listWebhooks();
60
+ const projectCards = projects
61
+ .map((project) => summarizeProject(project, runs, pendingApprovals))
62
+ .sort((a, b) => (
63
+ b.pendingApprovalCount - a.pendingApprovalCount ||
64
+ b.activeRunCount - a.activeRunCount ||
65
+ b.failedRunCount - a.failedRunCount ||
66
+ a.name.localeCompare(b.name)
67
+ ))
68
+ .slice(0, maxProjects);
69
+
70
+ return {
71
+ protocol: 'pixcode.control-room.v1',
72
+ generatedAt: new Date().toISOString(),
73
+ maxProjects,
74
+ mobileFirst: true,
75
+ totals: {
76
+ projects: projects.length,
77
+ activeRuns: runs.filter((run) => !TERMINAL_RUN_STATES.has(run.status)).length,
78
+ failedRuns: runs.filter((run) => run.status === 'failed').length,
79
+ pendingApprovals: pendingApprovals.length,
80
+ webhooks: webhooks.length,
81
+ enabledWebhooks: webhooks.filter((webhook) => webhook.enabled).length,
82
+ },
83
+ projects: projectCards,
84
+ approvals: pendingApprovals.slice(0, 20),
85
+ webhooks,
86
+ };
87
+ }
88
+
89
+ export function buildMobileConsoleLayout() {
90
+ return {
91
+ protocol: 'pixcode.remote-console-layout.v1',
92
+ mobileFirst: true,
93
+ sections: [
94
+ { id: 'projects', title: 'Projects', priority: 1 },
95
+ { id: 'approvals', title: 'Approval queue', priority: 2 },
96
+ { id: 'runs', title: 'Runs', priority: 3 },
97
+ { id: 'webhooks', title: 'Webhooks', priority: 4 },
98
+ { id: 'api', title: 'API SDK', priority: 5 },
99
+ ],
100
+ maxVisibleProjects: 4,
101
+ };
102
+ }