handmux 0.23.0 → 0.25.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/README.md +31 -8
- package/README.zh-CN.md +31 -8
- package/bin/handmux.js +14 -688
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +1015 -0
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +64 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/dist/hooks/handmux-write.cjs +264 -0
- package/dist/package.json +127 -0
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +203 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +262 -0
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +39 -0
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +94 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +86 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +525 -0
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +290 -0
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +80 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +39 -0
- package/dist/src/cli/i18n/en.js +392 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +391 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +154 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +117 -0
- package/dist/src/cli/statusLine.js +115 -0
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +3436 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +137 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +835 -0
- package/dist/src/codexUsageSnapshot.js +228 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +284 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +96 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +167 -0
- package/dist/src/paneInput.js +42 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +124 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +89 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +244 -0
- package/dist/src/routes/system.js +220 -0
- package/dist/src/routes/terminal.js +244 -0
- package/dist/src/routes/workspace.js +267 -0
- package/dist/src/server.js +315 -0
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +185 -172
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +261 -0
- package/dist/src/transcriptReader.js +209 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +31 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +301 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +363 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +704 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +30 -17
- package/hooks/handmux-notify.sh +0 -22
- package/hooks/handmux-write.cjs +0 -78
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/claude.js +0 -159
- package/src/agents/codex.js +0 -168
- package/src/agents/index.js +0 -21
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/claudeHooks.js +0 -206
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/statusLine.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/server.js +0 -186
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fsp from 'node:fs/promises';
|
|
3
|
+
import { ProjectTaskError, requireExpectedVersion, requireTrimmedString, } from './schema.js';
|
|
4
|
+
function projectFromRow(row) {
|
|
5
|
+
return {
|
|
6
|
+
id: row.id,
|
|
7
|
+
name: row.name,
|
|
8
|
+
rootPath: row.root_path,
|
|
9
|
+
repositoryRoot: row.repository_root,
|
|
10
|
+
defaultAgent: row.default_agent,
|
|
11
|
+
executionMode: row.execution_mode,
|
|
12
|
+
version: row.version,
|
|
13
|
+
archivedAt: row.archived_at,
|
|
14
|
+
createdAt: row.created_at,
|
|
15
|
+
updatedAt: row.updated_at,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function rowOf(value) {
|
|
19
|
+
return value && typeof value === 'object' ? value : null;
|
|
20
|
+
}
|
|
21
|
+
function taskFromRow(row) {
|
|
22
|
+
return {
|
|
23
|
+
id: row.id,
|
|
24
|
+
projectId: row.project_id,
|
|
25
|
+
title: row.title,
|
|
26
|
+
objective: row.objective,
|
|
27
|
+
acceptanceCriteria: JSON.parse(row.acceptance_criteria_json),
|
|
28
|
+
scope: row.scope,
|
|
29
|
+
constraints: row.constraints,
|
|
30
|
+
references: JSON.parse(row.references_json),
|
|
31
|
+
status: row.status,
|
|
32
|
+
priority: row.priority,
|
|
33
|
+
briefVersion: row.brief_version,
|
|
34
|
+
version: row.version,
|
|
35
|
+
archivedAt: row.archived_at,
|
|
36
|
+
createdAt: row.created_at,
|
|
37
|
+
updatedAt: row.updated_at,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function taskEventFromRow(row) {
|
|
41
|
+
return {
|
|
42
|
+
id: row.id,
|
|
43
|
+
taskId: row.task_id,
|
|
44
|
+
type: row.type,
|
|
45
|
+
actor: row.actor,
|
|
46
|
+
taskVersion: row.task_version,
|
|
47
|
+
briefVersion: row.brief_version,
|
|
48
|
+
payload: JSON.parse(row.payload_json),
|
|
49
|
+
createdAt: row.created_at,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const TASK_PRIORITIES = new Set(['none', 'high', 'medium', 'low']);
|
|
53
|
+
const BRIEF_FIELDS = ['title', 'objective', 'acceptanceCriteria', 'scope', 'constraints', 'references'];
|
|
54
|
+
function optionalText(value, field) {
|
|
55
|
+
if (value == null || value === '')
|
|
56
|
+
return null;
|
|
57
|
+
if (typeof value !== 'string') {
|
|
58
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, `${field} must be text or null`);
|
|
59
|
+
}
|
|
60
|
+
return value.trim() || null;
|
|
61
|
+
}
|
|
62
|
+
function objective(value) {
|
|
63
|
+
if (value == null)
|
|
64
|
+
return '';
|
|
65
|
+
if (typeof value !== 'string') {
|
|
66
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'objective must be text');
|
|
67
|
+
}
|
|
68
|
+
return value.trim();
|
|
69
|
+
}
|
|
70
|
+
function acceptanceCriteria(value) {
|
|
71
|
+
if (value == null)
|
|
72
|
+
return [];
|
|
73
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
74
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'acceptanceCriteria must be a list of text items');
|
|
75
|
+
}
|
|
76
|
+
return value.map((item) => item.trim()).filter(Boolean);
|
|
77
|
+
}
|
|
78
|
+
function references(value) {
|
|
79
|
+
if (value == null)
|
|
80
|
+
return [];
|
|
81
|
+
if (!Array.isArray(value)) {
|
|
82
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'references must be a list');
|
|
83
|
+
}
|
|
84
|
+
return value.map((item) => {
|
|
85
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
86
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'each reference must be an object');
|
|
87
|
+
}
|
|
88
|
+
const record = item;
|
|
89
|
+
if (record.kind !== 'url' && record.kind !== 'path') {
|
|
90
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'reference kind must be url or path');
|
|
91
|
+
}
|
|
92
|
+
if (typeof record.value !== 'string' || !record.value.trim()) {
|
|
93
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'reference value is required');
|
|
94
|
+
}
|
|
95
|
+
if (record.label != null && typeof record.label !== 'string') {
|
|
96
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'reference label must be text or null');
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
kind: record.kind,
|
|
100
|
+
value: record.value.trim(),
|
|
101
|
+
label: typeof record.label === 'string' ? record.label.trim() || null : null,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function priority(value) {
|
|
106
|
+
const result = value ?? 'none';
|
|
107
|
+
if (typeof result !== 'string' || !TASK_PRIORITIES.has(result)) {
|
|
108
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'priority is invalid');
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
function status(value) {
|
|
113
|
+
if (value !== 'draft' && value !== 'ready') {
|
|
114
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'status must be draft or ready');
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
function validateReady(task) {
|
|
119
|
+
if (!task.objective) {
|
|
120
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'A task needs a goal');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function sameJson(left, right) {
|
|
124
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
125
|
+
}
|
|
126
|
+
export function createProjectTaskStore(db, { realpath = fsp.realpath, isDirectory = async (target) => (await fsp.stat(target)).isDirectory(), resolveRepositoryRoot = async () => null, randomUUID = crypto.randomUUID, now = () => new Date(), onSuccessfulWrite = () => { }, } = {}) {
|
|
127
|
+
const selectProject = db.prepare(`
|
|
128
|
+
SELECT id, name, root_path, repository_root, default_agent, execution_mode,
|
|
129
|
+
version, archived_at, created_at, updated_at
|
|
130
|
+
FROM projects WHERE id = ?
|
|
131
|
+
`);
|
|
132
|
+
const selectTask = db.prepare(`
|
|
133
|
+
SELECT id, project_id, title, objective, acceptance_criteria_json, scope, constraints,
|
|
134
|
+
references_json, status, priority, brief_version, version, archived_at, created_at, updated_at
|
|
135
|
+
FROM tasks WHERE id = ?
|
|
136
|
+
`);
|
|
137
|
+
const selectActiveProjectRoot = db.prepare(`
|
|
138
|
+
SELECT id FROM projects WHERE root_path = ? AND archived_at IS NULL
|
|
139
|
+
`);
|
|
140
|
+
const insertTaskEvent = db.prepare(`
|
|
141
|
+
INSERT INTO task_events (
|
|
142
|
+
id, task_id, type, actor, task_version, brief_version, payload_json, created_at
|
|
143
|
+
) VALUES (?, ?, ?, 'user', ?, ?, ?, ?)
|
|
144
|
+
`);
|
|
145
|
+
const notifyWrite = () => {
|
|
146
|
+
Promise.resolve(onSuccessfulWrite()).catch(() => { });
|
|
147
|
+
};
|
|
148
|
+
const requireProject = (id) => {
|
|
149
|
+
const row = rowOf(selectProject.get(id));
|
|
150
|
+
if (!row)
|
|
151
|
+
throw new ProjectTaskError('PROJECT_NOT_FOUND', 404, 'Project no longer exists');
|
|
152
|
+
return projectFromRow(row);
|
|
153
|
+
};
|
|
154
|
+
const requireTask = (id) => {
|
|
155
|
+
const row = selectTask.get(id);
|
|
156
|
+
if (!row)
|
|
157
|
+
throw new ProjectTaskError('TASK_NOT_FOUND', 404, 'Task no longer exists');
|
|
158
|
+
return taskFromRow(row);
|
|
159
|
+
};
|
|
160
|
+
const assertTaskProjectActive = (task) => {
|
|
161
|
+
if (requireProject(task.projectId).archivedAt) {
|
|
162
|
+
throw new ProjectTaskError('PROJECT_NOT_FOUND', 404, 'Project is archived');
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const transaction = (operation) => {
|
|
166
|
+
db.exec('BEGIN IMMEDIATE');
|
|
167
|
+
try {
|
|
168
|
+
const result = operation();
|
|
169
|
+
db.exec('COMMIT');
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
try {
|
|
174
|
+
db.exec('ROLLBACK');
|
|
175
|
+
}
|
|
176
|
+
catch { }
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const appendTaskEvent = (task, type, payload) => {
|
|
181
|
+
insertTaskEvent.run(randomUUID(), task.id, type, task.version, task.briefVersion, JSON.stringify(payload), task.updatedAt);
|
|
182
|
+
};
|
|
183
|
+
const assertTaskVersion = (task, rawVersion) => {
|
|
184
|
+
const expectedVersion = requireExpectedVersion(rawVersion, 'TASK_VALIDATION');
|
|
185
|
+
if (task.version !== expectedVersion) {
|
|
186
|
+
throw new ProjectTaskError('VERSION_CONFLICT', 409, 'Task changed; refresh before saving again');
|
|
187
|
+
}
|
|
188
|
+
return expectedVersion;
|
|
189
|
+
};
|
|
190
|
+
return {
|
|
191
|
+
async listProjects({ archived = false } = {}) {
|
|
192
|
+
const rows = db.prepare(`
|
|
193
|
+
SELECT id, name, root_path, repository_root, default_agent, execution_mode,
|
|
194
|
+
version, archived_at, created_at, updated_at
|
|
195
|
+
FROM projects
|
|
196
|
+
WHERE ${archived ? 'archived_at IS NOT NULL' : 'archived_at IS NULL'}
|
|
197
|
+
ORDER BY updated_at DESC, id
|
|
198
|
+
`).all();
|
|
199
|
+
return rows.map(projectFromRow);
|
|
200
|
+
},
|
|
201
|
+
async getProject(id) {
|
|
202
|
+
return requireProject(id);
|
|
203
|
+
},
|
|
204
|
+
async createProject(input) {
|
|
205
|
+
const name = requireTrimmedString(input.name, 'name', 120, 'PROJECT_VALIDATION');
|
|
206
|
+
if (typeof input.rootPath !== 'string' || !input.rootPath) {
|
|
207
|
+
throw new ProjectTaskError('PROJECT_VALIDATION', 400, 'rootPath must be an absolute directory');
|
|
208
|
+
}
|
|
209
|
+
let rootPath;
|
|
210
|
+
try {
|
|
211
|
+
rootPath = await realpath(input.rootPath);
|
|
212
|
+
if (!await isDirectory(rootPath))
|
|
213
|
+
throw new Error('not a directory');
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
throw new ProjectTaskError('PROJECT_VALIDATION', 400, 'rootPath must be an existing directory');
|
|
217
|
+
}
|
|
218
|
+
const existing = selectActiveProjectRoot.get(rootPath);
|
|
219
|
+
if (existing) {
|
|
220
|
+
throw new ProjectTaskError('PROJECT_ROOT_EXISTS', 409, 'This directory is already a Project');
|
|
221
|
+
}
|
|
222
|
+
const id = randomUUID();
|
|
223
|
+
const timestamp = now().toISOString();
|
|
224
|
+
const repositoryRoot = await resolveRepositoryRoot(rootPath);
|
|
225
|
+
// Repository discovery is async. Recheck at the synchronous write boundary so two concurrent
|
|
226
|
+
// Add Project requests cannot both pass the first lookup and leak a raw SQLite constraint error.
|
|
227
|
+
if (selectActiveProjectRoot.get(rootPath)) {
|
|
228
|
+
throw new ProjectTaskError('PROJECT_ROOT_EXISTS', 409, 'This directory is already a Project');
|
|
229
|
+
}
|
|
230
|
+
db.prepare(`
|
|
231
|
+
INSERT INTO projects (
|
|
232
|
+
id, name, root_path, repository_root, default_agent, execution_mode,
|
|
233
|
+
version, archived_at, created_at, updated_at
|
|
234
|
+
) VALUES (?, ?, ?, ?, NULL, 'project-root', 1, NULL, ?, ?)
|
|
235
|
+
`).run(id, name, rootPath, repositoryRoot, timestamp, timestamp);
|
|
236
|
+
notifyWrite();
|
|
237
|
+
return requireProject(id);
|
|
238
|
+
},
|
|
239
|
+
async updateProject(id, input) {
|
|
240
|
+
const name = requireTrimmedString(input.name, 'name', 120, 'PROJECT_VALIDATION');
|
|
241
|
+
const expectedVersion = requireExpectedVersion(input.expectedVersion);
|
|
242
|
+
const timestamp = now().toISOString();
|
|
243
|
+
const result = db.prepare(`
|
|
244
|
+
UPDATE projects
|
|
245
|
+
SET name = ?, version = version + 1, updated_at = ?
|
|
246
|
+
WHERE id = ? AND version = ? AND archived_at IS NULL
|
|
247
|
+
`).run(name, timestamp, id, expectedVersion);
|
|
248
|
+
if (result.changes === 0) {
|
|
249
|
+
const current = requireProject(id);
|
|
250
|
+
if (current.archivedAt) {
|
|
251
|
+
throw new ProjectTaskError('PROJECT_NOT_FOUND', 404, 'Project is archived');
|
|
252
|
+
}
|
|
253
|
+
throw new ProjectTaskError('VERSION_CONFLICT', 409, 'Project changed; refresh before saving again');
|
|
254
|
+
}
|
|
255
|
+
notifyWrite();
|
|
256
|
+
return requireProject(id);
|
|
257
|
+
},
|
|
258
|
+
async archiveProject(id, input) {
|
|
259
|
+
const expectedVersion = requireExpectedVersion(input.expectedVersion);
|
|
260
|
+
const timestamp = now().toISOString();
|
|
261
|
+
const result = db.prepare(`
|
|
262
|
+
UPDATE projects
|
|
263
|
+
SET archived_at = ?, version = version + 1, updated_at = ?
|
|
264
|
+
WHERE id = ? AND version = ? AND archived_at IS NULL
|
|
265
|
+
`).run(timestamp, timestamp, id, expectedVersion);
|
|
266
|
+
if (result.changes === 0) {
|
|
267
|
+
const current = requireProject(id);
|
|
268
|
+
if (current.archivedAt)
|
|
269
|
+
return current;
|
|
270
|
+
throw new ProjectTaskError('VERSION_CONFLICT', 409, 'Project changed; refresh before archiving');
|
|
271
|
+
}
|
|
272
|
+
notifyWrite();
|
|
273
|
+
return requireProject(id);
|
|
274
|
+
},
|
|
275
|
+
async listTasks({ projectId, bucket }) {
|
|
276
|
+
if (typeof projectId !== 'string' || !projectId) {
|
|
277
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'projectId is required');
|
|
278
|
+
}
|
|
279
|
+
requireProject(projectId);
|
|
280
|
+
const clauses = {
|
|
281
|
+
tasks: "archived_at IS NULL AND status = 'ready'",
|
|
282
|
+
drafts: "archived_at IS NULL AND status = 'draft'",
|
|
283
|
+
canceled: "archived_at IS NULL AND status = 'canceled'",
|
|
284
|
+
archived: 'archived_at IS NOT NULL',
|
|
285
|
+
};
|
|
286
|
+
if (typeof bucket !== 'string' || !Object.hasOwn(clauses, bucket)) {
|
|
287
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'bucket must be tasks, drafts, canceled, or archived');
|
|
288
|
+
}
|
|
289
|
+
const rows = db.prepare(`
|
|
290
|
+
SELECT id, project_id, title, objective, acceptance_criteria_json, scope, constraints,
|
|
291
|
+
references_json, status, priority, brief_version, version, archived_at, created_at, updated_at
|
|
292
|
+
FROM tasks
|
|
293
|
+
WHERE project_id = ? AND ${clauses[bucket]}
|
|
294
|
+
ORDER BY updated_at DESC, id
|
|
295
|
+
`).all(projectId);
|
|
296
|
+
return rows.map(taskFromRow);
|
|
297
|
+
},
|
|
298
|
+
async getTask(id) {
|
|
299
|
+
return requireTask(id);
|
|
300
|
+
},
|
|
301
|
+
async createTask(input) {
|
|
302
|
+
if (typeof input.projectId !== 'string' || !input.projectId) {
|
|
303
|
+
throw new ProjectTaskError('TASK_VALIDATION', 400, 'projectId is required');
|
|
304
|
+
}
|
|
305
|
+
const project = requireProject(input.projectId);
|
|
306
|
+
if (project.archivedAt)
|
|
307
|
+
throw new ProjectTaskError('PROJECT_NOT_FOUND', 404, 'Project is archived');
|
|
308
|
+
const taskStatus = status(input.status);
|
|
309
|
+
const task = {
|
|
310
|
+
id: randomUUID(),
|
|
311
|
+
projectId: project.id,
|
|
312
|
+
title: requireTrimmedString(input.title, 'title', 240, 'TASK_VALIDATION'),
|
|
313
|
+
objective: objective(input.objective),
|
|
314
|
+
acceptanceCriteria: acceptanceCriteria(input.acceptanceCriteria),
|
|
315
|
+
scope: optionalText(input.scope, 'scope'),
|
|
316
|
+
constraints: optionalText(input.constraints, 'constraints'),
|
|
317
|
+
references: references(input.references),
|
|
318
|
+
status: taskStatus,
|
|
319
|
+
priority: priority(input.priority),
|
|
320
|
+
briefVersion: 1,
|
|
321
|
+
version: 1,
|
|
322
|
+
archivedAt: null,
|
|
323
|
+
createdAt: now().toISOString(),
|
|
324
|
+
updatedAt: '',
|
|
325
|
+
};
|
|
326
|
+
task.updatedAt = task.createdAt;
|
|
327
|
+
if (task.status === 'ready')
|
|
328
|
+
validateReady(task);
|
|
329
|
+
transaction(() => {
|
|
330
|
+
db.prepare(`
|
|
331
|
+
INSERT INTO tasks (
|
|
332
|
+
id, project_id, title, objective, acceptance_criteria_json, scope, constraints,
|
|
333
|
+
references_json, status, priority, brief_version, version, archived_at, created_at, updated_at
|
|
334
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 1, NULL, ?, ?)
|
|
335
|
+
`).run(task.id, task.projectId, task.title, task.objective, JSON.stringify(task.acceptanceCriteria), task.scope, task.constraints, JSON.stringify(task.references), task.status, task.priority, task.createdAt, task.updatedAt);
|
|
336
|
+
appendTaskEvent(task, 'task.created', { status: task.status });
|
|
337
|
+
});
|
|
338
|
+
notifyWrite();
|
|
339
|
+
return requireTask(task.id);
|
|
340
|
+
},
|
|
341
|
+
async updateTask(id, input) {
|
|
342
|
+
const current = requireTask(id);
|
|
343
|
+
assertTaskProjectActive(current);
|
|
344
|
+
assertTaskVersion(current, input.expectedVersion);
|
|
345
|
+
if (current.archivedAt)
|
|
346
|
+
throw new ProjectTaskError('TASK_NOT_FOUND', 404, 'Task is archived');
|
|
347
|
+
if (current.status === 'canceled') {
|
|
348
|
+
throw new ProjectTaskError('TASK_TRANSITION_INVALID', 409, 'Canceled tasks are read-only');
|
|
349
|
+
}
|
|
350
|
+
const next = {
|
|
351
|
+
...current,
|
|
352
|
+
title: Object.hasOwn(input, 'title')
|
|
353
|
+
? requireTrimmedString(input.title, 'title', 240, 'TASK_VALIDATION') : current.title,
|
|
354
|
+
objective: Object.hasOwn(input, 'objective') ? objective(input.objective) : current.objective,
|
|
355
|
+
acceptanceCriteria: Object.hasOwn(input, 'acceptanceCriteria')
|
|
356
|
+
? acceptanceCriteria(input.acceptanceCriteria) : current.acceptanceCriteria,
|
|
357
|
+
scope: Object.hasOwn(input, 'scope') ? optionalText(input.scope, 'scope') : current.scope,
|
|
358
|
+
constraints: Object.hasOwn(input, 'constraints')
|
|
359
|
+
? optionalText(input.constraints, 'constraints') : current.constraints,
|
|
360
|
+
references: Object.hasOwn(input, 'references') ? references(input.references) : current.references,
|
|
361
|
+
priority: Object.hasOwn(input, 'priority') ? priority(input.priority) : current.priority,
|
|
362
|
+
};
|
|
363
|
+
if (next.status === 'ready')
|
|
364
|
+
validateReady(next);
|
|
365
|
+
const changedFields = [
|
|
366
|
+
...BRIEF_FIELDS.filter((field) => !sameJson(current[field], next[field])),
|
|
367
|
+
...(current.priority === next.priority ? [] : ['priority']),
|
|
368
|
+
];
|
|
369
|
+
if (changedFields.length === 0)
|
|
370
|
+
return current;
|
|
371
|
+
const briefChanged = changedFields.some((field) => BRIEF_FIELDS.includes(field));
|
|
372
|
+
next.version += 1;
|
|
373
|
+
if (briefChanged)
|
|
374
|
+
next.briefVersion += 1;
|
|
375
|
+
next.updatedAt = now().toISOString();
|
|
376
|
+
transaction(() => {
|
|
377
|
+
db.prepare(`
|
|
378
|
+
UPDATE tasks SET
|
|
379
|
+
title = ?, objective = ?, acceptance_criteria_json = ?, scope = ?, constraints = ?,
|
|
380
|
+
references_json = ?, priority = ?, brief_version = ?, version = ?, updated_at = ?
|
|
381
|
+
WHERE id = ? AND version = ?
|
|
382
|
+
`).run(next.title, next.objective, JSON.stringify(next.acceptanceCriteria), next.scope, next.constraints, JSON.stringify(next.references), next.priority, next.briefVersion, next.version, next.updatedAt, next.id, current.version);
|
|
383
|
+
appendTaskEvent(next, 'task.updated', { changedFields });
|
|
384
|
+
});
|
|
385
|
+
notifyWrite();
|
|
386
|
+
return requireTask(id);
|
|
387
|
+
},
|
|
388
|
+
async promoteTask(id, input) {
|
|
389
|
+
const current = requireTask(id);
|
|
390
|
+
assertTaskProjectActive(current);
|
|
391
|
+
assertTaskVersion(current, input.expectedVersion);
|
|
392
|
+
if (current.archivedAt)
|
|
393
|
+
throw new ProjectTaskError('TASK_NOT_FOUND', 404, 'Task is archived');
|
|
394
|
+
if (current.status !== 'draft') {
|
|
395
|
+
throw new ProjectTaskError('TASK_TRANSITION_INVALID', 409, 'Only a draft can become a task');
|
|
396
|
+
}
|
|
397
|
+
validateReady(current);
|
|
398
|
+
const next = { ...current, status: 'ready', version: current.version + 1, updatedAt: now().toISOString() };
|
|
399
|
+
transaction(() => {
|
|
400
|
+
db.prepare(`
|
|
401
|
+
UPDATE tasks SET status = 'ready', version = ?, updated_at = ? WHERE id = ? AND version = ?
|
|
402
|
+
`).run(next.version, next.updatedAt, id, current.version);
|
|
403
|
+
appendTaskEvent(next, 'task.promoted', { from: 'draft', to: 'ready' });
|
|
404
|
+
});
|
|
405
|
+
notifyWrite();
|
|
406
|
+
return requireTask(id);
|
|
407
|
+
},
|
|
408
|
+
async cancelTask(id, input) {
|
|
409
|
+
const current = requireTask(id);
|
|
410
|
+
assertTaskProjectActive(current);
|
|
411
|
+
assertTaskVersion(current, input.expectedVersion);
|
|
412
|
+
if (current.archivedAt)
|
|
413
|
+
throw new ProjectTaskError('TASK_NOT_FOUND', 404, 'Task is archived');
|
|
414
|
+
if (current.status === 'canceled') {
|
|
415
|
+
throw new ProjectTaskError('TASK_TRANSITION_INVALID', 409, 'Task is already canceled');
|
|
416
|
+
}
|
|
417
|
+
const next = { ...current, status: 'canceled', version: current.version + 1, updatedAt: now().toISOString() };
|
|
418
|
+
transaction(() => {
|
|
419
|
+
db.prepare(`
|
|
420
|
+
UPDATE tasks SET status = 'canceled', version = ?, updated_at = ? WHERE id = ? AND version = ?
|
|
421
|
+
`).run(next.version, next.updatedAt, id, current.version);
|
|
422
|
+
appendTaskEvent(next, 'task.canceled', { from: current.status, to: 'canceled' });
|
|
423
|
+
});
|
|
424
|
+
notifyWrite();
|
|
425
|
+
return requireTask(id);
|
|
426
|
+
},
|
|
427
|
+
async archiveTask(id, input) {
|
|
428
|
+
const current = requireTask(id);
|
|
429
|
+
assertTaskProjectActive(current);
|
|
430
|
+
assertTaskVersion(current, input.expectedVersion);
|
|
431
|
+
if (current.archivedAt)
|
|
432
|
+
return current;
|
|
433
|
+
const next = {
|
|
434
|
+
...current,
|
|
435
|
+
version: current.version + 1,
|
|
436
|
+
archivedAt: now().toISOString(),
|
|
437
|
+
updatedAt: now().toISOString(),
|
|
438
|
+
};
|
|
439
|
+
transaction(() => {
|
|
440
|
+
db.prepare(`
|
|
441
|
+
UPDATE tasks SET archived_at = ?, version = ?, updated_at = ? WHERE id = ? AND version = ?
|
|
442
|
+
`).run(next.archivedAt, next.version, next.updatedAt, id, current.version);
|
|
443
|
+
appendTaskEvent(next, 'task.archived', { archived: true });
|
|
444
|
+
});
|
|
445
|
+
notifyWrite();
|
|
446
|
+
return requireTask(id);
|
|
447
|
+
},
|
|
448
|
+
async listTaskEvents(id) {
|
|
449
|
+
requireTask(id);
|
|
450
|
+
const rows = db.prepare(`
|
|
451
|
+
SELECT id, task_id, type, actor, task_version, brief_version, payload_json, created_at
|
|
452
|
+
FROM task_events WHERE task_id = ? ORDER BY task_version, created_at, id
|
|
453
|
+
`).all(id);
|
|
454
|
+
return rows.map(taskEventFromRow);
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
package/dist/src/push.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Web Push (VAPID) — minimal delivery layer. Sends notifications to subscribed devices via the
|
|
2
|
+
// browser/OS push services (Android=FCM, iOS=APNs); the server only talks to those services, never
|
|
3
|
+
// to the phone directly, so device reachability/queueing is their problem (see TTL/topic below).
|
|
4
|
+
//
|
|
5
|
+
// Subscriptions are stored as records: { subscription, boundSessions }. The subscription field is
|
|
6
|
+
// the raw PushSubscription the browser hands us ({endpoint, keys:{p256dh, auth}}); boundSessions is
|
|
7
|
+
// the list of session names this device cares about (used by sendToSession for targeted delivery). A
|
|
8
|
+
// dead subscription (404/410 from the push service) is pruned on the next send.
|
|
9
|
+
import webpush from 'web-push';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { readJsonArray, writeJsonAtomic } from './jsonStore.js';
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const STORE = process.env.PUSH_STORE || path.resolve(here, '../data/push-subs.json');
|
|
16
|
+
// VAPID is optional: without keys every send is a no-op, so the server still boots in an
|
|
17
|
+
// environment that hasn't generated keys (configured=false surfaces as a 503 on /vapid). Push delivers
|
|
18
|
+
// whenever VAPID is configured — there is no separate "dev vs prod server" anymore (one config file, one
|
|
19
|
+
// `handmux start`). If you run a second instance on the same host and don't want it to deliver, leave
|
|
20
|
+
// `vapid` out of that instance's config. Init is LAZY (first use) so the module is import-safe.
|
|
21
|
+
let inited = false;
|
|
22
|
+
let configured = false;
|
|
23
|
+
function ensureInit() {
|
|
24
|
+
if (inited)
|
|
25
|
+
return;
|
|
26
|
+
inited = true;
|
|
27
|
+
const vapidPublic = process.env.VAPID_PUBLIC;
|
|
28
|
+
const vapidPrivate = process.env.VAPID_PRIVATE;
|
|
29
|
+
configured = !!(vapidPublic && vapidPrivate);
|
|
30
|
+
if (vapidPublic && vapidPrivate) {
|
|
31
|
+
webpush.setVapidDetails(
|
|
32
|
+
// Apple (APNs) rejects a VAPID subject on a fake/.local domain with BadJwtToken — it must be a
|
|
33
|
+
// valid mailto:/https: with a real-looking domain. example.com is reserved and accepted by both.
|
|
34
|
+
process.env.VAPID_SUBJECT || 'mailto:admin@example.com', vapidPublic, vapidPrivate);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const genKey = () => crypto.randomBytes(18).toString('base64url');
|
|
38
|
+
const VALID_PUSH_KEY = /^[A-Za-z0-9_-]{16,128}$/;
|
|
39
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
40
|
+
const strings = (value) => Array.isArray(value)
|
|
41
|
+
? value.filter((item) => typeof item === 'string' && item.length > 0)
|
|
42
|
+
: [];
|
|
43
|
+
const errorField = (error, field) => isRecord(error) ? error[field] : undefined;
|
|
44
|
+
export function parsePushSubscription(value) {
|
|
45
|
+
if (!isRecord(value) || typeof value.endpoint !== 'string' || !value.endpoint)
|
|
46
|
+
return null;
|
|
47
|
+
if (!isRecord(value.keys) || typeof value.keys.p256dh !== 'string' || typeof value.keys.auth !== 'string')
|
|
48
|
+
return null;
|
|
49
|
+
const subscription = {
|
|
50
|
+
endpoint: value.endpoint,
|
|
51
|
+
keys: { p256dh: value.keys.p256dh, auth: value.keys.auth },
|
|
52
|
+
};
|
|
53
|
+
if (value.expirationTime === null || (typeof value.expirationTime === 'number' && Number.isFinite(value.expirationTime))) {
|
|
54
|
+
subscription.expirationTime = value.expirationTime;
|
|
55
|
+
}
|
|
56
|
+
return subscription;
|
|
57
|
+
}
|
|
58
|
+
function parseStoredSubscription(value) {
|
|
59
|
+
const wrapped = isRecord(value) && 'subscription' in value;
|
|
60
|
+
const subscription = parsePushSubscription(wrapped && isRecord(value) ? value.subscription : value);
|
|
61
|
+
if (!subscription)
|
|
62
|
+
return null;
|
|
63
|
+
const record = {
|
|
64
|
+
subscription,
|
|
65
|
+
boundSessions: wrapped && isRecord(value) ? strings(value.boundSessions) : [],
|
|
66
|
+
};
|
|
67
|
+
if (wrapped && isRecord(value) && typeof value.pushKey === 'string' && VALID_PUSH_KEY.test(value.pushKey)) {
|
|
68
|
+
record.pushKey = value.pushKey;
|
|
69
|
+
}
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
let subs = load();
|
|
73
|
+
function load() {
|
|
74
|
+
return readJsonArray(STORE)
|
|
75
|
+
.map(parseStoredSubscription)
|
|
76
|
+
.filter((record) => record !== null);
|
|
77
|
+
}
|
|
78
|
+
function persist() { writeJsonAtomic(STORE, subs); }
|
|
79
|
+
export function isConfigured() { ensureInit(); return configured; }
|
|
80
|
+
export function publicKey() { ensureInit(); return process.env.VAPID_PUBLIC || null; }
|
|
81
|
+
export function count() { return subs.length; }
|
|
82
|
+
export function addSubscription(sub, boundSessions = [], preferredPushKey = null) {
|
|
83
|
+
const parsed = parsePushSubscription(sub);
|
|
84
|
+
if (!parsed)
|
|
85
|
+
return false;
|
|
86
|
+
const sessions = strings(boundSessions);
|
|
87
|
+
const endpointRecord = subs.find((s) => s.subscription.endpoint === parsed.endpoint);
|
|
88
|
+
const requestedKey = typeof preferredPushKey === 'string' && VALID_PUSH_KEY.test(preferredPushKey) ? preferredPushKey : null;
|
|
89
|
+
const keyRecord = requestedKey ? subs.find((s) => s.pushKey === requestedKey) : null;
|
|
90
|
+
const pushKey = keyRecord?.pushKey || endpointRecord?.pushKey || requestedKey || genKey();
|
|
91
|
+
// A browser PushSubscription is transport state, not device identity. Re-enabling push can produce a
|
|
92
|
+
// different endpoint; replace the old endpoint that owns this stable key instead of creating a new
|
|
93
|
+
// script-push target. Also collapse any legacy duplicate for the new endpoint.
|
|
94
|
+
subs = subs.filter((s) => s.subscription.endpoint !== parsed.endpoint && s.pushKey !== pushKey);
|
|
95
|
+
subs.push({ subscription: parsed, boundSessions: sessions, pushKey });
|
|
96
|
+
persist();
|
|
97
|
+
return pushKey;
|
|
98
|
+
}
|
|
99
|
+
export function updateBound(endpoint, boundSessions = []) {
|
|
100
|
+
const rec = subs.find((s) => s.subscription.endpoint === endpoint);
|
|
101
|
+
if (rec) {
|
|
102
|
+
rec.boundSessions = strings(boundSessions);
|
|
103
|
+
persist();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function removeSubscription(endpoint) {
|
|
107
|
+
const removed = subs.find((s) => s.subscription.endpoint === endpoint);
|
|
108
|
+
subs = subs.filter((s) => s.subscription.endpoint !== endpoint);
|
|
109
|
+
if (removed)
|
|
110
|
+
persist();
|
|
111
|
+
return removed?.pushKey || null;
|
|
112
|
+
}
|
|
113
|
+
// The device-addressing id (NOT an auth credential — see /api/push/send-local). Lazy-generate for
|
|
114
|
+
// records stored before the feature existed so an already-subscribed device still has one.
|
|
115
|
+
export function getPushKey(endpoint) {
|
|
116
|
+
const rec = subs.find((s) => s.subscription.endpoint === endpoint);
|
|
117
|
+
if (!rec)
|
|
118
|
+
return null;
|
|
119
|
+
if (!rec.pushKey) {
|
|
120
|
+
rec.pushKey = genKey();
|
|
121
|
+
persist();
|
|
122
|
+
}
|
|
123
|
+
return rec.pushKey;
|
|
124
|
+
}
|
|
125
|
+
// The push-service Topic header (RFC 8030) must be ≤32 URL/filename-safe base64 chars [A-Za-z0-9_-].
|
|
126
|
+
// An invalid topic makes the service reject the ENTIRE send with a non-404/410 error that deliver()
|
|
127
|
+
// swallows — a silent zero-delivery, no log, no prune. tmux pane ids carry a '%' (e.g. %4), so a
|
|
128
|
+
// pane-derived topic ("pane-%4") breaks every per-pane push (需要你 / 已完成). Sanitize here, the layer
|
|
129
|
+
// that owns the web-push contract, so no caller has to know the rule.
|
|
130
|
+
function safeTopic(value) {
|
|
131
|
+
if (typeof value !== 'string' || !value)
|
|
132
|
+
return undefined;
|
|
133
|
+
const s = value.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 32);
|
|
134
|
+
return s || undefined;
|
|
135
|
+
}
|
|
136
|
+
// TTL bounds staleness (a phone offline longer than this drops the push instead of getting it
|
|
137
|
+
// hours later); topic collapses older undelivered messages with the same key so a device coming
|
|
138
|
+
// back online sees only the latest per topic. urgency hints the OS how aggressively to wake.
|
|
139
|
+
function options(opts = {}) {
|
|
140
|
+
const ttl = typeof opts.ttl === 'number' && Number.isFinite(opts.ttl) ? opts.ttl : 90;
|
|
141
|
+
const urgency = ['very-low', 'low', 'normal', 'high'].includes(String(opts.urgency))
|
|
142
|
+
? String(opts.urgency) : 'normal';
|
|
143
|
+
const result = { TTL: ttl, urgency };
|
|
144
|
+
const topic = safeTopic(opts.topic);
|
|
145
|
+
if (topic)
|
|
146
|
+
result.topic = topic;
|
|
147
|
+
return result;
|
|
148
|
+
}
|
|
149
|
+
export function classifyDeliveryFailure(error) {
|
|
150
|
+
const status = Number(errorField(error, 'statusCode')) || 0;
|
|
151
|
+
if (status === 404 || status === 410)
|
|
152
|
+
return 'expired';
|
|
153
|
+
if (status === 429)
|
|
154
|
+
return 'rate_limited';
|
|
155
|
+
if (status >= 500)
|
|
156
|
+
return 'service_unavailable';
|
|
157
|
+
if (status >= 400)
|
|
158
|
+
return 'rejected';
|
|
159
|
+
return 'network_error';
|
|
160
|
+
}
|
|
161
|
+
async function deliver(records, payload, opts = {}) {
|
|
162
|
+
ensureInit();
|
|
163
|
+
if (!configured)
|
|
164
|
+
return {
|
|
165
|
+
sent: 0, failed: 0, gone: 0, configured: false,
|
|
166
|
+
deliveries: records.map((rec) => ({ pushKey: rec.pushKey, status: 'failed', reason: 'not_configured' })),
|
|
167
|
+
};
|
|
168
|
+
const data = JSON.stringify(payload);
|
|
169
|
+
const dead = [];
|
|
170
|
+
const deliveries = [];
|
|
171
|
+
let sent = 0;
|
|
172
|
+
let failed = 0;
|
|
173
|
+
await Promise.all(records.map(async (rec) => {
|
|
174
|
+
try {
|
|
175
|
+
await webpush.sendNotification(rec.subscription, data, options(opts));
|
|
176
|
+
sent += 1;
|
|
177
|
+
deliveries.push({ pushKey: rec.pushKey, status: 'success' });
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
failed += 1;
|
|
181
|
+
const statusCode = Number(errorField(e, 'statusCode')) || 0;
|
|
182
|
+
if (statusCode === 404 || statusCode === 410)
|
|
183
|
+
dead.push(rec.subscription.endpoint);
|
|
184
|
+
deliveries.push({ pushKey: rec.pushKey, status: 'failed', reason: classifyDeliveryFailure(e) });
|
|
185
|
+
let host = 'unknown push service';
|
|
186
|
+
try {
|
|
187
|
+
host = new URL(rec.subscription.endpoint).host;
|
|
188
|
+
}
|
|
189
|
+
catch { /* malformed endpoints fail below */ }
|
|
190
|
+
const detail = String(errorField(e, 'body') || errorField(e, 'message') || 'unknown error').replace(/\s+/g, ' ').slice(0, 200);
|
|
191
|
+
console.warn(`[handmux] push delivery failed (${host}, HTTP ${statusCode || 'unknown'}): ${detail}`);
|
|
192
|
+
}
|
|
193
|
+
}));
|
|
194
|
+
if (dead.length) {
|
|
195
|
+
subs = subs.filter((s) => !dead.includes(s.subscription.endpoint));
|
|
196
|
+
persist();
|
|
197
|
+
}
|
|
198
|
+
return { sent, failed, gone: dead.length, configured: true, deliveries };
|
|
199
|
+
}
|
|
200
|
+
export const sendToAll = (payload, opts = {}) => deliver(subs, payload, opts);
|
|
201
|
+
export const sendToSession = (session, payload, opts = {}) => deliver(subs.filter((s) => s.boundSessions.includes(session)), payload, opts);
|
|
202
|
+
export const sendToDevices = (keys, payload, opts = {}) => deliver(subs.filter((s) => typeof s.pushKey === 'string' && keys.includes(s.pushKey)), payload, opts);
|
|
203
|
+
// Union of the given sessions, deduped: a device bound to several of them is still delivered once
|
|
204
|
+
// (deliver() iterates the record list, so each record appears at most once here).
|
|
205
|
+
export const sendToSessions = (sessions, payload, opts = {}) => deliver(subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x))), payload, opts);
|
|
206
|
+
// The pushKeys a given scope resolves to (mirrors sendToDevices/sendToSessions/sendToAll targeting) — used
|
|
207
|
+
// by the inbox to write a record into exactly the devices a manual push is delivered to.
|
|
208
|
+
export const resolveTargetKeys = ({ devices, sessions } = {}) => {
|
|
209
|
+
const pick = (devices && devices.length)
|
|
210
|
+
? subs.filter((s) => typeof s.pushKey === 'string' && devices.includes(s.pushKey))
|
|
211
|
+
: (sessions && sessions.length)
|
|
212
|
+
? subs.filter((s) => s.boundSessions.some((x) => sessions.includes(x)))
|
|
213
|
+
: subs;
|
|
214
|
+
return [...new Set(pick.map((s) => s.pushKey).filter((key) => typeof key === 'string'))];
|
|
215
|
+
};
|
|
216
|
+
// Back-compat: the /push/subscribe welcome still pushes to a single just-added subscription.
|
|
217
|
+
export async function sendToOne(sub, payload, opts = {}) {
|
|
218
|
+
const parsed = parsePushSubscription(sub);
|
|
219
|
+
if (!parsed)
|
|
220
|
+
return { sent: 0, failed: 1, gone: 0, configured: isConfigured(), deliveries: [] };
|
|
221
|
+
const rec = subs.find((s) => s.subscription.endpoint === parsed.endpoint)
|
|
222
|
+
?? { subscription: parsed, boundSessions: [] };
|
|
223
|
+
return deliver([rec], payload, opts);
|
|
224
|
+
}
|