@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.
- package/dist/assets/index-B-_FofJ_.css +32 -0
- package/dist/assets/{index-nefOyhzb.js → index-CDKI7Ucy.js} +140 -140
- package/dist/index.html +2 -2
- package/dist-server/server/index.js +3 -0
- package/dist-server/server/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/index.js +1 -0
- package/dist-server/server/modules/orchestration/index.js.map +1 -1
- package/dist-server/server/modules/orchestration/workflows/approval-queue.js +72 -0
- package/dist-server/server/modules/orchestration/workflows/approval-queue.js.map +1 -0
- package/dist-server/server/modules/orchestration/workflows/workflow-runner.js +25 -0
- package/dist-server/server/modules/orchestration/workflows/workflow-runner.js.map +1 -1
- package/dist-server/server/modules/orchestration/workflows/workflow.routes.js +87 -0
- package/dist-server/server/modules/orchestration/workflows/workflow.routes.js.map +1 -1
- package/dist-server/server/routes/public-api.js +7 -1
- package/dist-server/server/routes/public-api.js.map +1 -1
- package/dist-server/server/routes/remote.js +18 -0
- package/dist-server/server/routes/remote.js.map +1 -1
- package/dist-server/server/routes/webhooks.js +53 -0
- package/dist-server/server/routes/webhooks.js.map +1 -0
- package/dist-server/server/services/control-room.js +89 -0
- package/dist-server/server/services/control-room.js.map +1 -0
- package/dist-server/server/services/public-api-manifest.js +96 -0
- package/dist-server/server/services/public-api-manifest.js.map +1 -1
- package/dist-server/server/services/telegram/control-center.js +110 -0
- package/dist-server/server/services/telegram/control-center.js.map +1 -1
- package/dist-server/server/services/telegram/translations.js +24 -2
- package/dist-server/server/services/telegram/translations.js.map +1 -1
- package/dist-server/server/services/webhooks.js +198 -0
- package/dist-server/server/services/webhooks.js.map +1 -0
- package/package.json +1 -1
- package/scripts/smoke/v143-remote-control.mjs +76 -0
- package/server/index.js +4 -0
- package/server/modules/orchestration/index.ts +4 -0
- package/server/modules/orchestration/workflows/approval-queue.ts +106 -0
- package/server/modules/orchestration/workflows/workflow-runner.ts +25 -0
- package/server/modules/orchestration/workflows/workflow.routes.ts +95 -0
- package/server/routes/public-api.js +14 -1
- package/server/routes/remote.js +22 -0
- package/server/routes/webhooks.js +63 -0
- package/server/services/control-room.js +102 -0
- package/server/services/public-api-manifest.js +98 -0
- package/server/services/telegram/control-center.js +113 -0
- package/server/services/telegram/translations.js +24 -2
- package/server/services/webhooks.js +216 -0
- 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 {
|
|
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;
|
package/server/routes/remote.js
CHANGED
|
@@ -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
|
+
}
|