@worca/app 1.3.0-rc.2 → 1.4.0-rc.1
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 +85 -6
- package/agents/clarify.meta.json +1 -0
- package/agents/memoryDefragmenter.meta.json +2 -1
- package/agents/reviewer.meta.json +60 -0
- package/agents/worca-cc-code-reviewer.md +33 -0
- package/agents/worca-cc-memory-defragmenter.md +5 -3
- package/agents/workspaceScanner.meta.json +1 -0
- package/package.json +14 -10
- package/scripts/git-diff.mjs +25 -0
- package/scripts/gitDiff.meta.json +18 -0
- package/scripts/js-inline.mjs +11 -0
- package/scripts/js.meta.json +22 -0
- package/scripts/py-inline.py +27 -0
- package/scripts/py.meta.json +22 -0
- package/scripts/shell.meta.json +24 -0
- package/skills/worca/SKILL.md +3 -2
- package/src/cli/models.mjs +247 -0
- package/src/cli/render.mjs +72 -4
- package/src/cli/schedule.mjs +494 -0
- package/src/cli/worca-cc.mjs +1001 -22
- package/src/core/agent-registry.mjs +75 -23
- package/src/core/agent-store.mjs +51 -2
- package/src/core/artifacts.mjs +73 -10
- package/src/core/ask/events.mjs +119 -1
- package/src/core/ask/limits.mjs +32 -4
- package/src/core/ask/mcp-stdio.mjs +12 -0
- package/src/core/ask/model-deps.mjs +126 -0
- package/src/core/ask/model-proposal.mjs +370 -0
- package/src/core/ask/models.mjs +12 -0
- package/src/core/ask/policy-deps.mjs +124 -0
- package/src/core/ask/policy-proposal.mjs +363 -0
- package/src/core/ask/prompt.mjs +74 -9
- package/src/core/ask/proposal.mjs +54 -5
- package/src/core/ask/schedule-deps.mjs +83 -0
- package/src/core/ask/schedule-spec.mjs +310 -0
- package/src/core/ask/script-deps.mjs +357 -0
- package/src/core/ask/source-deps.mjs +52 -0
- package/src/core/ask/source-spec.mjs +157 -0
- package/src/core/ask/spawn.mjs +1 -0
- package/src/core/ask/store.mjs +6 -3
- package/src/core/ask/tool-deps.mjs +4 -0
- package/src/core/ask/tools.mjs +657 -37
- package/src/core/ask/turn.mjs +109 -2
- package/src/core/ask-files.mjs +406 -0
- package/src/core/ask-forms.mjs +195 -0
- package/src/core/ask-projection.mjs +72 -0
- package/src/core/bridge/errors.mjs +84 -0
- package/src/core/bridge/provider-ops.mjs +281 -0
- package/src/core/bridge/providers/copilot.mjs +269 -0
- package/src/core/bridge/providers/endpoint.mjs +257 -0
- package/src/core/bridge/registry.mjs +88 -0
- package/src/core/bridge/semaphore.mjs +73 -0
- package/src/core/bridge/server.mjs +184 -0
- package/src/core/bridge/telemetry.mjs +53 -0
- package/src/core/bridge/translate/request.mjs +252 -0
- package/src/core/bridge/translate/response.mjs +82 -0
- package/src/core/bridge/translate/stream.mjs +242 -0
- package/src/core/bridge/upstream.mjs +209 -0
- package/src/core/chat/command-router.mjs +58 -4
- package/src/core/chat/notifier.mjs +14 -1
- package/src/core/chat/renderers.mjs +35 -0
- package/src/core/claude-runner.mjs +126 -19
- package/src/core/config.mjs +212 -31
- package/src/core/cost-budget.mjs +3 -2
- package/src/core/db.mjs +169 -15
- package/src/core/failure-policy.mjs +10 -0
- package/src/core/fs-browse.mjs +16 -4
- package/src/core/git-info.mjs +22 -0
- package/src/core/graph/builtin-workflows.mjs +3 -1
- package/src/core/graph/exec-io.mjs +71 -0
- package/src/core/graph/executor.mjs +139 -70
- package/src/core/graph/human-evidence.mjs +131 -0
- package/src/core/graph/python-probe.mjs +172 -0
- package/src/core/graph/registry-ports.mjs +10 -6
- package/src/core/graph/scheduler.mjs +39 -24
- package/src/core/graph/script-child.mjs +81 -0
- package/src/core/graph/script-runner.mjs +597 -0
- package/src/core/graph/worca_script.py +207 -0
- package/src/core/guardrail-store.mjs +16 -0
- package/src/core/human-backfill.mjs +108 -0
- package/src/core/human-rate.mjs +17 -0
- package/src/core/index-html.mjs +6 -2
- package/src/core/memory-defrag-model.mjs +112 -0
- package/src/core/memory-store.mjs +70 -18
- package/src/core/memory-sync.mjs +22 -11
- package/src/core/metrics/read.mjs +4 -1
- package/src/core/metrics/record.mjs +50 -2
- package/src/core/metrics/sync.mjs +6 -4
- package/src/core/model-env.mjs +149 -0
- package/src/core/model-test.mjs +14 -1
- package/src/core/notifications.mjs +128 -0
- package/src/core/onboarding.mjs +8 -2
- package/src/core/orchestrator.mjs +357 -26
- package/src/core/phases.mjs +95 -6
- package/src/core/plugin-api.mjs +24 -7
- package/src/core/plugin-manifest.mjs +184 -18
- package/src/core/plugin-models.mjs +1 -0
- package/src/core/plugin-script-cases.mjs +118 -0
- package/src/core/plugin-store.mjs +163 -17
- package/src/core/plugin-workflows.mjs +71 -17
- package/src/core/policy/cache.mjs +116 -0
- package/src/core/policy/effective.mjs +175 -0
- package/src/core/policy/gate.mjs +91 -0
- package/src/core/policy/local.mjs +145 -0
- package/src/core/policy/registry.mjs +330 -0
- package/src/core/policy/scope.mjs +61 -0
- package/src/core/policy/state.mjs +79 -0
- package/src/core/policy/sync.mjs +513 -0
- package/src/core/protocol.mjs +43 -0
- package/src/core/run-harness.mjs +421 -72
- package/src/core/scheduler.mjs +980 -0
- package/src/core/script-bench.mjs +628 -0
- package/src/core/script-registry.mjs +116 -0
- package/src/core/script-store.mjs +563 -0
- package/src/core/settings.mjs +489 -14
- package/src/core/stats.mjs +33 -2
- package/src/core/workflow-export.mjs +94 -3
- package/src/core/workflow-share.mjs +67 -18
- package/src/core/workflows.mjs +47 -11
- package/src/core/workspaces.mjs +18 -12
- package/src/shared/forms/answer.mjs +164 -0
- package/src/shared/forms/catalog.mjs +91 -0
- package/src/shared/forms/form-def.mjs +290 -0
- package/src/shared/forms/layout.mjs +67 -0
- package/src/shared/forms/paths.mjs +47 -0
- package/src/shared/forms/project.mjs +309 -0
- package/src/shared/forms/schema.mjs +205 -0
- package/src/shared/graph/agent-meta.mjs +55 -5
- package/src/shared/graph/constants.mjs +14 -2
- package/src/shared/graph/flow-layout.mjs +2 -1
- package/src/shared/graph/isomorphic.mjs +5 -3
- package/src/shared/graph/manifest.mjs +22 -13
- package/src/shared/graph/ports.mjs +45 -19
- package/src/shared/graph/script-cases.mjs +257 -0
- package/src/shared/graph/script-icons.mjs +46 -0
- package/src/shared/graph/script-infer.mjs +259 -0
- package/src/shared/graph/script-meta.mjs +408 -0
- package/src/shared/graph/script-templates.mjs +201 -0
- package/src/shared/graph/template.mjs +4 -4
- package/src/shared/graph/validate.mjs +89 -16
- package/src/shared/human-estimate.mjs +100 -0
- package/src/shared/schedule/recurrence.mjs +353 -0
- package/src/shared/team-metrics/aggregate.mjs +51 -11
- package/{scripts → tools}/install.mjs +3 -3
- package/ui/public/app.js +4390 -683
- package/ui/public/artifact-picker.mjs +189 -0
- package/ui/public/ask/dom.mjs +121 -0
- package/ui/public/ask/form-preview.mjs +55 -0
- package/ui/public/ask/form-renderer.mjs +250 -0
- package/ui/public/ask/registry.mjs +53 -0
- package/ui/public/ask/widgets-display.mjs +370 -0
- package/ui/public/ask/widgets-input.mjs +624 -0
- package/ui/public/ask/widgets-layout.mjs +90 -0
- package/ui/public/ask-panel.mjs +401 -27
- package/ui/public/ask-run-card.mjs +1 -1
- package/ui/public/bridge-view.mjs +694 -0
- package/ui/public/chat-settings-view.mjs +24 -0
- package/ui/public/code-editor.mjs +181 -0
- package/ui/public/getting-started.mjs +34 -7
- package/ui/public/graph/composer.mjs +138 -10
- package/ui/public/graph/inspector.mjs +61 -58
- package/ui/public/graph/palette.mjs +27 -9
- package/ui/public/graph/run-decor.mjs +34 -17
- package/ui/public/graph/run-hosts.mjs +7 -1
- package/ui/public/graph/save-dialog.mjs +3 -0
- package/ui/public/graph/view.mjs +23 -7
- package/ui/public/guardrails-view.mjs +15 -3
- package/ui/public/guide-spot.mjs +87 -9
- package/ui/public/index.html +480 -141
- package/ui/public/memory-view.mjs +22 -4
- package/ui/public/models-view.mjs +162 -17
- package/ui/public/node-tunables.mjs +33 -4
- package/ui/public/plugins-view.mjs +23 -1
- package/ui/public/results-view.mjs +4 -2
- package/ui/public/schedule-sheet.mjs +430 -0
- package/ui/public/schedules-view.mjs +432 -0
- package/ui/public/script-bench-view.mjs +1154 -0
- package/ui/public/script-forms.mjs +282 -0
- package/ui/public/script-wizard.mjs +529 -0
- package/ui/public/scripts-view.mjs +868 -0
- package/ui/public/stats-view.mjs +159 -52
- package/ui/public/style.css +1461 -44
- package/ui/public/team-metrics-surfaces.mjs +77 -16
- package/ui/public/team-metrics-view.mjs +68 -5
- package/ui/public/team-policy-view.mjs +1402 -0
- package/ui/public/ui-level.mjs +237 -0
- package/ui/server.mjs +1827 -73
package/ui/server.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import fsp from 'node:fs/promises';
|
|
|
15
15
|
import process from 'node:process';
|
|
16
16
|
import { createRequire } from 'node:module';
|
|
17
17
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
18
|
-
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
18
|
+
import { randomUUID, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
19
19
|
|
|
20
20
|
import { preflightNode } from '../src/core/preflight-node.mjs';
|
|
21
21
|
import { createOrchestratorFor } from '../src/core/engine-select.mjs';
|
|
@@ -24,9 +24,11 @@ import {
|
|
|
24
24
|
enrichPipelinesPr, reconcileStaleRunning, readPipelineForResume, persistPrState, readPrState,
|
|
25
25
|
readRunLogText, readRunArtifactText, countPipelines, runRootSweepLookups, legacySweepLookups, slugify,
|
|
26
26
|
listArtifacts, listRunArtifacts, lookupPipelineRow, findPipelineRowById, readPipelineStateById, resolveIndexedArtifact, resolveIndexedArtifactForRow,
|
|
27
|
-
readPromptFile,
|
|
27
|
+
readPromptFile, runDirForRow,
|
|
28
28
|
} from '../src/core/artifacts.mjs';
|
|
29
29
|
import { DIFF_PATCH_FILE } from '../src/core/results.mjs';
|
|
30
|
+
import { readAskFileEntry, ASK_FILE_MIMES } from '../src/core/ask-files.mjs';
|
|
31
|
+
import { ASK_FILES_DIR } from '../src/core/ask-forms.mjs';
|
|
30
32
|
import { protectedSectionKeys } from '../src/core/diff-anchor.mjs';
|
|
31
33
|
import {
|
|
32
34
|
addDiffComment, addDiffCommentReply, listDiffComments, getDiffComment, setDiffCommentResolved,
|
|
@@ -40,15 +42,21 @@ import {
|
|
|
40
42
|
rawProjectsRoot, defaultProjectsRoot, runRootMode,
|
|
41
43
|
pipelineCostLimitUsd, totalCostLimitUsd, costLimitResetPeriod,
|
|
42
44
|
setPipelineCostLimitUsd, setTotalCostLimitUsd, setCostLimitResetPeriod, assertCostLimitInputs,
|
|
45
|
+
humanRateUsdPerHour, setHumanRateUsdPerHour, assertHumanRateInput,
|
|
43
46
|
askMaxTurns, askMaxBudgetUsd, setAskMaxTurns, setAskMaxBudgetUsd, assertAskLimitInputs,
|
|
44
47
|
chatPrefs, setChatPrefs,
|
|
45
48
|
debugSpawnEnabled as storedDebugSpawnEnabled, effectiveDebugSpawn, setDebugSpawnEnabled, assertDebugSpawnInput, SETTINGS_POST_KEYS,
|
|
46
49
|
titleModel as storedTitleModel, setTitleModel, assertTitleModelInput,
|
|
47
50
|
hideBuiltinModels, setHideBuiltinModels, assertHideBuiltinModelsInput,
|
|
48
51
|
theme as storedTheme, setTheme, assertThemeInput,
|
|
52
|
+
uiLevel as storedUiLevel, setUiLevel, assertUiLevelInput, defaultUiLevel,
|
|
49
53
|
autoWorkflowModel as storedAutoWorkflowModel, setAutoWorkflowModel, assertAutoWorkflowModelInput,
|
|
54
|
+
memoryDefragModel, setMemoryDefragModel, assertMemoryDefragModelInput,
|
|
55
|
+
scheduleDefaults, setScheduleDefaults,
|
|
50
56
|
} from '../src/core/settings.mjs';
|
|
57
|
+
import { resolveDefragModel, defragDefaultModel, defragWorkflowView, checkStartPair } from '../src/core/memory-defrag-model.mjs';
|
|
51
58
|
import { describeTitleModel } from '../src/core/title.mjs';
|
|
59
|
+
import { effectiveHumanRateUsd } from '../src/core/human-rate.mjs';
|
|
52
60
|
import {
|
|
53
61
|
ASK_ID_RE, createThread as askCreateThread, getThread as askGetThread,
|
|
54
62
|
listThreads as askListThreads, updateThread as askUpdateThread,
|
|
@@ -73,6 +81,7 @@ import {
|
|
|
73
81
|
buildTurnPrompt as askBuildTurnPrompt, buildRestoredPrompt as askBuildRestoredPrompt,
|
|
74
82
|
selectInlineAttachments as askSelectInlineAttachments, validateClientContext,
|
|
75
83
|
} from '../src/core/ask/prompt.mjs';
|
|
84
|
+
import { askScriptPromptInput } from '../src/core/ask/script-deps.mjs';
|
|
76
85
|
import {
|
|
77
86
|
classifyExtension as askClassifyExtension, sniffMime as askSniffMime,
|
|
78
87
|
} from '../src/core/ask/attachment-kind.mjs';
|
|
@@ -86,13 +95,25 @@ import { createAskTurn } from '../src/core/ask/turn.mjs';
|
|
|
86
95
|
import { attachRunFollower } from '../src/core/ask/follow.mjs';
|
|
87
96
|
import { mockEnabled, MOCK_WRITER_ROLES } from '../src/core/claude-runner.mjs';
|
|
88
97
|
import { budgetStatus, readCostCapOverride, setCostCapOverride } from '../src/core/cost-budget.mjs';
|
|
89
|
-
import { getStats } from '../src/core/stats.mjs';
|
|
98
|
+
import { getStats, budgetWindowSavings } from '../src/core/stats.mjs';
|
|
90
99
|
import {
|
|
91
100
|
enableTeamMetrics, setRecordMyRuns, flushSlug, flushAll, flushProject, scheduleFlush, discoverProject,
|
|
92
101
|
discoverAll, scanMembers, routeWorkspaceMembers, projectMetricsStatus, startTeamMetricsBackground,
|
|
93
102
|
metricsEvents, slugDirName, autoMetricsHome,
|
|
94
103
|
} from '../src/core/metrics/sync.mjs';
|
|
95
104
|
import { readScope, listScopes, parseScopeParam, aggregate, resolveRange, GROUP_BYS, PROJECT_KEY_RE as TM_PROJECT_KEY_RE } from '../src/core/metrics/read.mjs';
|
|
105
|
+
// Team policy (team-policy design §9, §11): the worca-policy branch, its gates and its pages.
|
|
106
|
+
import {
|
|
107
|
+
policyEvents, discoverPolicy, discoverAllPolicies, resolveProjectPolicy, resolveWorkspacePolicy, enableTeamPolicy, publishPolicy,
|
|
108
|
+
projectPolicyStatus, listPolicyScopes, routeWorkspaceMembersPolicy, autoPolicyHome, startTeamPolicyBackground,
|
|
109
|
+
} from '../src/core/policy/sync.mjs';
|
|
110
|
+
import { deviationsFor, fieldsForRun, capSummary } from '../src/core/policy/effective.mjs';
|
|
111
|
+
import { installedPluginsMap, pluginRequirements, blockedPluginFindings, seedPolicyMarketplaces, WORCA_VERSION as POLICY_WORCA_VERSION } from '../src/core/policy/local.mjs';
|
|
112
|
+
import { normalizePolicyDoc } from '../src/core/policy/registry.mjs';
|
|
113
|
+
import { checkTeamTotalGate, checkTeamPipelineGate, teamCapsForTarget } from '../src/core/policy/gate.mjs';
|
|
114
|
+
import { readPolicyState } from '../src/core/policy/state.mjs';
|
|
115
|
+
import { policyForScope, policyPayload } from '../src/core/policy/scope.mjs';
|
|
116
|
+
import { policyCatalogModels } from '../src/core/policy/cache.mjs';
|
|
96
117
|
import { pickFolderNative } from '../src/core/folder-dialog.mjs';
|
|
97
118
|
import { listFolders } from '../src/core/fs-browse.mjs';
|
|
98
119
|
import {
|
|
@@ -103,7 +124,14 @@ import {
|
|
|
103
124
|
readPrRemotePrefs, setPrRemotePrefs,
|
|
104
125
|
} from '../src/core/config.mjs';
|
|
105
126
|
import { listGlobalModels, addGlobalModel, updateGlobalModel } from '../src/core/settings.mjs';
|
|
106
|
-
import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue } from '../src/core/model-env.mjs';
|
|
127
|
+
import { modelEnvRef, maskModelEnvValue, SUBAGENT_MODEL_VALUES, subagentModelIssue, UPSTREAM_PROVIDERS } from '../src/core/model-env.mjs';
|
|
128
|
+
import { providerReadiness } from '../src/core/bridge/registry.mjs';
|
|
129
|
+
import { startBridge } from '../src/core/bridge/server.mjs';
|
|
130
|
+
import {
|
|
131
|
+
providersState, patchProvider, acknowledgeTerms, beginCopilotLogin, pollCopilotLogin, copilotLogout,
|
|
132
|
+
copilotModelsForImport, importCopilotModels, testProviderConnection,
|
|
133
|
+
endpointModelsForImport, importEndpointModels,
|
|
134
|
+
} from '../src/core/bridge/provider-ops.mjs';
|
|
107
135
|
import { listPluginModels, modelSecretsSchema, pluginModelSecretStatus } from '../src/core/plugin-models.mjs';
|
|
108
136
|
import { testModel } from '../src/core/model-test.mjs';
|
|
109
137
|
import {
|
|
@@ -111,7 +139,7 @@ import {
|
|
|
111
139
|
} from '../src/core/ui-instance.mjs';
|
|
112
140
|
import { validateGuardrails } from '../src/core/guardrails.mjs';
|
|
113
141
|
import {
|
|
114
|
-
listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet,
|
|
142
|
+
listBuiltinGuardrailSets, listGuardrailSets, readGuardrailSet, listPolicyGuardrailSets, isPolicyGuardrailSetId,
|
|
115
143
|
writeGuardrailSet, deleteGuardrailSet, isBuiltinGuardrailSetId,
|
|
116
144
|
} from '../src/core/guardrail-store.mjs';
|
|
117
145
|
import {
|
|
@@ -124,17 +152,25 @@ import {
|
|
|
124
152
|
} from '../src/core/ask/workflow-deps.mjs';
|
|
125
153
|
import { applyMetricsChange } from '../src/core/ask/metrics-deps.mjs';
|
|
126
154
|
import { metricsEventPrompt, metricsNoticeText } from '../src/core/ask/metrics-proposal.mjs';
|
|
155
|
+
import { applyPolicyChange } from '../src/core/ask/policy-deps.mjs';
|
|
156
|
+
import { policyEventPrompt, policyNoticeText } from '../src/core/ask/policy-proposal.mjs';
|
|
157
|
+
import { scheduleEventPrompt, scheduleNoticeText } from '../src/core/ask/schedule-spec.mjs';
|
|
158
|
+
import { applyModelChange } from '../src/core/ask/model-deps.mjs';
|
|
159
|
+
import { modelEventPrompt, modelNoticeText } from '../src/core/ask/model-proposal.mjs';
|
|
127
160
|
import { registryPortsFn } from '../src/core/graph/registry-ports.mjs';
|
|
128
|
-
import { sweepV1Runs, V1_RUN_RETIRED } from '../src/core/db.mjs';
|
|
161
|
+
import { sweepV1Runs, V1_RUN_RETIRED, getDb } from '../src/core/db.mjs';
|
|
129
162
|
import { exportWorkflow, exportWorkflowPlugin, ON_CONFLICT_MODES, RESOLUTION_CHOICES } from '../src/core/workflow-export.mjs';
|
|
130
163
|
import {
|
|
131
164
|
saveGraphWorkflow, importGraphWorkflow, exportGraphJson, workflowFileSlug, nodeDefaultsError,
|
|
132
165
|
} from '../src/core/workflow-share.mjs';
|
|
133
166
|
import { loadAgentRegistry } from '../src/core/agent-registry.mjs';
|
|
167
|
+
import { loadScriptRegistry } from '../src/core/script-registry.mjs';
|
|
168
|
+
import { probePython, pythonRuntimeState } from '../src/core/graph/python-probe.mjs';
|
|
134
169
|
import {
|
|
135
170
|
listLocalBranches, currentBranch, isValidSourceRef, sweepRunRoots, sweepLegacyWorktreesAll,
|
|
136
171
|
} from '../src/core/worktree.mjs';
|
|
137
|
-
import { hasGh, pushBranch, createPr, createIssue, prMergeable, listRemotes, sameRepo } from '../src/core/git-info.mjs';
|
|
172
|
+
import { hasGh, pushBranch, createPr, createIssue, prMergeable, listRemotes, listRemoteBranches, sameRepo } from '../src/core/git-info.mjs';
|
|
173
|
+
import { isSyntacticRef } from '../src/core/ask/proposal.mjs';
|
|
138
174
|
import { archivePipeline, discardRetainedWorktrees } from '../src/core/pipeline-delete.mjs';
|
|
139
175
|
import {
|
|
140
176
|
listWorkspaces, readWorkspace, createWorkspace,
|
|
@@ -155,7 +191,14 @@ import { createWorkspaceScan } from '../src/core/workspace-scan.mjs';
|
|
|
155
191
|
import { createAgentGen } from '../src/core/agent-gen.mjs';
|
|
156
192
|
import { listAgents, readAgent, createAgent, updateAgent, deleteAgent, AGENT_KEY_RE } from '../src/core/agent-store.mjs';
|
|
157
193
|
import {
|
|
158
|
-
|
|
194
|
+
listScripts, readScript, createScript, updateScript, deleteScript, duplicateScript, writeCases,
|
|
195
|
+
SCRIPT_KEY_RE,
|
|
196
|
+
} from '../src/core/script-store.mjs';
|
|
197
|
+
|
|
198
|
+
import { createBench, sweepBenchDirs, benchRoot } from '../src/core/script-bench.mjs';
|
|
199
|
+
import { PORT_ID_RE } from '../src/shared/graph/constants.mjs';
|
|
200
|
+
import {
|
|
201
|
+
listInstalledPlugins, installPlugin, updatePlugin, uninstallPlugin, pythonNoticeFor,
|
|
159
202
|
setPluginEnabled, doctorPlugin, linkPlugin,
|
|
160
203
|
listOrphanPluginData, purgePluginData,
|
|
161
204
|
} from '../src/core/plugin-store.mjs';
|
|
@@ -180,7 +223,22 @@ import { TokenBucket } from '../src/core/chat/rate-limiter.mjs';
|
|
|
180
223
|
import { renderTest } from '../src/core/chat/renderers.mjs';
|
|
181
224
|
import { readPluginsLock, pluginCurrentDir } from '../src/core/plugins-lock.mjs';
|
|
182
225
|
import { normalizeManifest, validatePluginDir, PLUGIN_NAME_RE as MANIFEST_PLUGIN_NAME_RE } from '../src/core/plugin-manifest.mjs';
|
|
183
|
-
import { listTaskSources, retryWriteback } from '../src/core/sources.mjs';
|
|
226
|
+
import { listTaskSources, retryWriteback, resolveTaskInput } from '../src/core/sources.mjs';
|
|
227
|
+
import {
|
|
228
|
+
createTicket, getTicket, listTickets, updateTicket, cancelTicket, requestRunNow, setTicketPipeline,
|
|
229
|
+
createSchedule, getSchedule, listSchedules, updateSchedule, pauseSchedule, resumeSchedule, skipNext,
|
|
230
|
+
runScheduleNow, deleteSchedule, cancelForTarget, dependentsOfWorkflow, runDueTickets, recordOutcome,
|
|
231
|
+
recoverScheduler, purgeScheduler, scheduleCounts, scheduleStageDir, scheduleSignature,
|
|
232
|
+
resolveAfterRef, predecessorState, previousBranchesOf, dependentsOfRun, AFTER_POLICIES, afterRefOf,
|
|
233
|
+
chainBaseBranchesOf,
|
|
234
|
+
} from '../src/core/scheduler.mjs';
|
|
235
|
+
import {
|
|
236
|
+
onNotification, listNotifications, unreadCount, latestNotificationId, markRead, markAllRead, purgeNotifications,
|
|
237
|
+
} from '../src/core/notifications.mjs';
|
|
238
|
+
import {
|
|
239
|
+
normalizeRule, nextOccurrence, previewOccurrences, describeRule, parseScheduledFor, localDate,
|
|
240
|
+
isValidTimeZone, formatInstant, OVERLAP_POLICIES, MISSED_POLICIES,
|
|
241
|
+
} from '../src/shared/schedule/recurrence.mjs';
|
|
184
242
|
import { callSource, PluginOpError } from '../src/core/plugin-shim.mjs';
|
|
185
243
|
import { resolveAutoModel, AUTO_MODEL_ENV } from '../src/core/auto/model.mjs';
|
|
186
244
|
import {
|
|
@@ -327,6 +385,12 @@ const SCAN_EVENT_NAMES = ['scan-progress', 'scan-done', 'scan-error'];
|
|
|
327
385
|
// a NEW family in the SAME runs Map. createAgentGen emits many agentgen-progress
|
|
328
386
|
// then exactly one terminal agentgen-done OR agentgen-error.
|
|
329
387
|
const AGENTGEN_EVENT_NAMES = ['agentgen-progress', 'agentgen-done', 'agentgen-error'];
|
|
388
|
+
// The scriptbench-* family (Scripts workbench §4.1): one more family in the same
|
|
389
|
+
// runs Map. createBench emits many scriptbench-line then exactly one terminal
|
|
390
|
+
// scriptbench-done OR scriptbench-error. A bench is NOT a run: no DB row, no
|
|
391
|
+
// History entry, no ledger and no cost (W15) — only this buffer.
|
|
392
|
+
const SCRIPTBENCH_EVENT_NAMES = ['scriptbench-line', 'scriptbench-done', 'scriptbench-error'];
|
|
393
|
+
const MAX_BENCH_ENTRIES = 8; // finished bench entries kept in the runs Map (startScriptBench evicts the rest)
|
|
330
394
|
const MAX_BUFFER = 5000;
|
|
331
395
|
|
|
332
396
|
// ---------------------------------------------------------------------------
|
|
@@ -373,20 +437,23 @@ wss.on('connection', (ws, req) => {
|
|
|
373
437
|
let requestedRunId = null;
|
|
374
438
|
let requestedScanId = null;
|
|
375
439
|
let requestedGenId = null;
|
|
440
|
+
let requestedBenchId = null;
|
|
376
441
|
let requestedThreadId = null;
|
|
377
442
|
try {
|
|
378
443
|
const u = new URL(req.url, 'http://localhost');
|
|
379
444
|
requestedRunId = u.searchParams.get('runId');
|
|
380
445
|
requestedScanId = u.searchParams.get('scanId');
|
|
381
446
|
requestedGenId = u.searchParams.get('genId');
|
|
447
|
+
requestedBenchId = u.searchParams.get('benchId');
|
|
382
448
|
requestedThreadId = u.searchParams.get('threadId');
|
|
383
449
|
} catch {
|
|
384
450
|
requestedRunId = null;
|
|
385
451
|
requestedScanId = null;
|
|
386
452
|
requestedGenId = null;
|
|
453
|
+
requestedBenchId = null;
|
|
387
454
|
requestedThreadId = null;
|
|
388
455
|
}
|
|
389
|
-
const id = requestedRunId || requestedScanId || requestedGenId;
|
|
456
|
+
const id = requestedRunId || requestedScanId || requestedGenId || requestedBenchId;
|
|
390
457
|
|
|
391
458
|
send(ws, { type: 'hello', runs: summarizeRuns(), ask: askHello() });
|
|
392
459
|
|
|
@@ -410,7 +477,7 @@ wss.on('connection', (ws, req) => {
|
|
|
410
477
|
} catch {
|
|
411
478
|
return;
|
|
412
479
|
}
|
|
413
|
-
const subId = msg && msg.type === 'subscribe' ? (msg.runId || msg.scanId || msg.genId) : null;
|
|
480
|
+
const subId = msg && msg.type === 'subscribe' ? (msg.runId || msg.scanId || msg.genId || msg.benchId) : null;
|
|
414
481
|
if (subId && runs.has(subId)) {
|
|
415
482
|
replayEntry(ws, runs.get(subId));
|
|
416
483
|
}
|
|
@@ -490,6 +557,7 @@ function emitChanged(type, action) {
|
|
|
490
557
|
}
|
|
491
558
|
|
|
492
559
|
metricsEvents.on('changed', (e) => emitChanged('team-metrics-changed', e && e.action ? e.action : null));
|
|
560
|
+
policyEvents.on('changed', (e) => emitChanged('team-policy-changed', e && e.action ? e.action : null));
|
|
493
561
|
|
|
494
562
|
// Every comment mutation in THIS process (the REST routes below) pokes the open
|
|
495
563
|
// Diff tabs. A poke carries ids only — no payload, so it is idempotent and has no
|
|
@@ -582,7 +650,11 @@ function liveDefragRun(scopeKeyStr) {
|
|
|
582
650
|
}
|
|
583
651
|
|
|
584
652
|
function summarizeRuns() {
|
|
585
|
-
|
|
653
|
+
// W15: a bench is NOT a run. Its entry shares the runs Map for the WS replay
|
|
654
|
+
// plumbing only — leaving it here puts "bench: <key>" in every hello and the
|
|
655
|
+
// client's liveRuns() (which does NOT filter by kind) raises the rail's
|
|
656
|
+
// Running badge over an empty Running list.
|
|
657
|
+
return [...runs.values()].filter((r) => r.kind !== 'scriptbench').map((r) => ({
|
|
586
658
|
runId: r.id,
|
|
587
659
|
stepper: r.orch?.state?.stepper ?? null,
|
|
588
660
|
pipelineId: r.pipelineId || null,
|
|
@@ -712,7 +784,36 @@ function wireRun(entry) {
|
|
|
712
784
|
// Capture the on-disk pipeline short id the orchestrator stamps onto
|
|
713
785
|
// state.id after createPipeline. Guard so null in pre-createPipeline
|
|
714
786
|
// snapshots cannot overwrite a previously-captured value.
|
|
715
|
-
if (typeof payload.id === 'string' && payload.id)
|
|
787
|
+
if (typeof payload.id === 'string' && payload.id) {
|
|
788
|
+
const first = !entry.pipelineId;
|
|
789
|
+
entry.pipelineId = payload.id;
|
|
790
|
+
// Scheduled runs: the ticket learns the pipeline it became (provenance columns).
|
|
791
|
+
if (first && entry.ticketId) {
|
|
792
|
+
try { setTicketPipeline(entry.ticketId, payload.id); } catch (err) { console.error(`[worca-ui] ticket link failed: ${err && err.message ? err.message : err}`); }
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
if (entry.ticketId && (name === 'done' || name === 'error') && !entry._outcomeRecorded) {
|
|
797
|
+
// Scheduled runs: report how the fired run ended (feed item, failure streak).
|
|
798
|
+
// 'error' is the launch-error channel — a failure before the pipeline row exists.
|
|
799
|
+
entry._outcomeRecorded = true;
|
|
800
|
+
try {
|
|
801
|
+
recordOutcome(entry.ticketId, {
|
|
802
|
+
status: name === 'error' ? 'error' : ((payload && payload.status) || 'done'),
|
|
803
|
+
pipelineId: entry.pipelineId || null,
|
|
804
|
+
reason: (payload && payload.reason) || null,
|
|
805
|
+
detail: (payload && (payload.detail || payload.message)) || null,
|
|
806
|
+
});
|
|
807
|
+
emitChanged('schedules-changed', 'outcome');
|
|
808
|
+
} catch (err) { console.error(`[worca-ui] schedule outcome failed: ${err && err.message ? err.message : err}`); }
|
|
809
|
+
}
|
|
810
|
+
if ((name === 'done' || name === 'error') && !entry._chainNudged) {
|
|
811
|
+
// Run chains: a dependent waits on this run — open its gate now rather than at the next 30 s tick.
|
|
812
|
+
entry._chainNudged = true;
|
|
813
|
+
try {
|
|
814
|
+
const waiting = [...(entry.ticketId ? dependentsOfRun({ ticketId: entry.ticketId }) : []), ...(entry.pipelineId ? dependentsOfRun({ pipelineId: entry.pipelineId }) : [])];
|
|
815
|
+
if (waiting.length) setTimeout(() => { void schedulerTick(); }, 0);
|
|
816
|
+
} catch (err) { console.error(`[worca-ui] chain nudge failed: ${err && err.message ? err.message : err}`); }
|
|
716
817
|
}
|
|
717
818
|
if (name === 'title' && payload && typeof payload.title === 'string') {
|
|
718
819
|
// Keep the in-memory run fresh so a late-joining client's hello
|
|
@@ -788,6 +889,47 @@ function wireAgentGen(entry) {
|
|
|
788
889
|
}
|
|
789
890
|
}
|
|
790
891
|
|
|
892
|
+
// ---------------------------------------------------------------------------
|
|
893
|
+
// Wire a Bench's events onto the WebSocket, tagged with benchId. The
|
|
894
|
+
// scriptbench-* family: same runs Map, same ring-buffer/replay plumbing as
|
|
895
|
+
// wireAgentGen. createBench emits many scriptbench-line then exactly one
|
|
896
|
+
// terminal scriptbench-done OR scriptbench-error (run() never throws). The
|
|
897
|
+
// terminal result is kept on the entry: the output route reads its PATHS from
|
|
898
|
+
// there, never from the URL, and a reconnecting tab replays the buffer.
|
|
899
|
+
// ---------------------------------------------------------------------------
|
|
900
|
+
function wireScriptBench(entry) {
|
|
901
|
+
const { benchId, orch } = entry;
|
|
902
|
+
|
|
903
|
+
// Every frame carries a per-bench sequence number. The page learns its benchId
|
|
904
|
+
// from the POST answer and THEN subscribes, so for one round trip it receives a
|
|
905
|
+
// frame both live (the broadcast) and replayed (the buffer): `seq` is what lets
|
|
906
|
+
// it keep exactly one. It lives on the entry, not on events.length — the ring
|
|
907
|
+
// buffer is spliced at MAX_BUFFER.
|
|
908
|
+
const record = (event) => {
|
|
909
|
+
entry.seq = (entry.seq || 0) + 1;
|
|
910
|
+
const tagged = { ...event, benchId, seq: entry.seq };
|
|
911
|
+
entry.events.push(tagged);
|
|
912
|
+
if (entry.events.length > MAX_BUFFER) entry.events.splice(0, entry.events.length - MAX_BUFFER);
|
|
913
|
+
broadcast(tagged);
|
|
914
|
+
return tagged;
|
|
915
|
+
};
|
|
916
|
+
|
|
917
|
+
for (const name of SCRIPTBENCH_EVENT_NAMES) {
|
|
918
|
+
subscribe(orch, name, (payload) => {
|
|
919
|
+
const event = { type: name, ...(payload && typeof payload === 'object' ? payload : { value: payload }) };
|
|
920
|
+
if (name === 'scriptbench-line') { if (entry.status === 'running' || entry.status === 'created') entry.status = 'running'; }
|
|
921
|
+
else if (name === 'scriptbench-done') {
|
|
922
|
+
// A stop still ends in `done` with a stopped RESULT; the stop route has
|
|
923
|
+
// already marked the entry, so it is not overwritten here.
|
|
924
|
+
if (entry.status !== 'stopped') entry.status = 'done';
|
|
925
|
+
entry.result = (payload && payload.result) || null;
|
|
926
|
+
} else if (name === 'scriptbench-error') entry.status = 'error';
|
|
927
|
+
record(event);
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
|
|
791
933
|
// ---------------------------------------------------------------------------
|
|
792
934
|
// Teams ingress (chat-connectivity-design.md §4.7) — the ONE deliberate,
|
|
793
935
|
// auditable exemption from the loopback guard below: Bot Framework can only
|
|
@@ -876,6 +1018,9 @@ app.use((req, res, next) => {
|
|
|
876
1018
|
// over-budget upload still reaches the route's OWN clear 400/413, not a raw
|
|
877
1019
|
// parser error.
|
|
878
1020
|
app.post('/api/ask/threads/:id/messages', express.json({ limit: '64mb' }));
|
|
1021
|
+
// A script's saved cases are inline text: 32 cases x 256 KiB PER PORT is legal (workbench
|
|
1022
|
+
// spec §3.2) and does not fit the global 8 MB, so the one route that saves them all gets room.
|
|
1023
|
+
app.put('/api/scripts/:key/cases', express.json({ limit: '64mb' }));
|
|
879
1024
|
app.use(express.json({ limit: '8mb' }));
|
|
880
1025
|
|
|
881
1026
|
if (HLJS_ASSETS) {
|
|
@@ -951,13 +1096,30 @@ app.use('/src/shared', (_req, res) => {
|
|
|
951
1096
|
// design §5.2). Read per request (100 KB, local) so an index.html edit is live
|
|
952
1097
|
// without a restart, exactly like static serving was. no-store: a theme change
|
|
953
1098
|
// must never be served from the browser cache.
|
|
1099
|
+
// Interface mode (docs/ui-levels.md): the stored choice, else the install's default. "Fresh" =
|
|
1100
|
+
// the welcome dialog was never dismissed and the store holds no project and no run; anything
|
|
1101
|
+
// else is an install that predates the mode (or has outgrown it) and keeps the full UI.
|
|
1102
|
+
function effectiveUiLevel() {
|
|
1103
|
+
const stored = storedUiLevel();
|
|
1104
|
+
if (stored) return stored;
|
|
1105
|
+
let fresh = false;
|
|
1106
|
+
try { fresh = !onboardingPrefs().welcomeSeen && countProjects() === 0 && countPipelines() === 0; }
|
|
1107
|
+
catch { fresh = false; }
|
|
1108
|
+
return defaultUiLevel({ fresh });
|
|
1109
|
+
}
|
|
1110
|
+
// "Fresh" is derived from state the user changes (welcomeSeen, the first project), so a new user's
|
|
1111
|
+
// mode would jump from simple to expert the moment they act. Each of those writes pins the derived
|
|
1112
|
+
// value first; a stored choice is never overwritten.
|
|
1113
|
+
async function pinUiLevel() {
|
|
1114
|
+
if (!storedUiLevel()) await setUiLevel(effectiveUiLevel());
|
|
1115
|
+
}
|
|
954
1116
|
const INDEX_FILE = path.join(PUBLIC_DIR, 'index.html');
|
|
955
1117
|
if (!fs.readFileSync(INDEX_FILE, 'utf8').includes(INDEX_THEME_ANCHOR)) {
|
|
956
1118
|
throw new Error(`ui/public/index.html lost its theme anchor ${INDEX_THEME_ANCHOR}`);
|
|
957
1119
|
}
|
|
958
1120
|
function sendIndex(res) {
|
|
959
1121
|
let html;
|
|
960
|
-
try { html = renderIndexHtml(fs.readFileSync(INDEX_FILE, 'utf8'), storedTheme()); }
|
|
1122
|
+
try { html = renderIndexHtml(fs.readFileSync(INDEX_FILE, 'utf8'), storedTheme(), effectiveUiLevel()); }
|
|
961
1123
|
catch (err) { return res.status(500).json({ error: err && err.message ? err.message : 'shell unavailable' }); }
|
|
962
1124
|
res.set('Content-Type', 'text/html; charset=utf-8');
|
|
963
1125
|
res.set('Cache-Control', 'no-store');
|
|
@@ -1264,6 +1426,11 @@ function askTrackRun(threadId, input, pin) {
|
|
|
1264
1426
|
const startRunHandler = async (req, res) => {
|
|
1265
1427
|
try {
|
|
1266
1428
|
const body = req.body || {};
|
|
1429
|
+
// Scheduled runs: `internal` is set ONLY by fireTicket() (never from HTTP) — the
|
|
1430
|
+
// due ticket being started through this same gate. It carries the ticket, whose id
|
|
1431
|
+
// IS the runId, plus the CLI-only options a stored request may hold.
|
|
1432
|
+
const internal = req._internal && typeof req._internal === 'object' ? req._internal : null;
|
|
1433
|
+
const stored = internal && body.internal && typeof body.internal === 'object' ? body.internal : {};
|
|
1267
1434
|
|
|
1268
1435
|
// Mutual exclusion: exactly one of workspaceId / projectDir (§2.6).
|
|
1269
1436
|
const hasWorkspace = typeof body.workspaceId === 'string' && body.workspaceId.trim();
|
|
@@ -1291,12 +1458,24 @@ const startRunHandler = async (req, res) => {
|
|
|
1291
1458
|
if (!askGetThread(body.askThreadId)) return badRequest(res, 'unknown askThreadId');
|
|
1292
1459
|
const found = askFindCard(body.askThreadId, body.askCardId);
|
|
1293
1460
|
if (!found) return badRequest(res, 'unknown askCardId');
|
|
1294
|
-
|
|
1461
|
+
const ownScheduledCard = internal && found.block.state === 'scheduled' && found.block.runId === internal.ticket.id;
|
|
1462
|
+
if (found.block.state !== 'proposed' && !ownScheduledCard) {
|
|
1295
1463
|
return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
1296
1464
|
}
|
|
1297
1465
|
askLink = { threadId: body.askThreadId, cardId: body.askCardId };
|
|
1298
1466
|
}
|
|
1299
1467
|
|
|
1468
|
+
// Scheduled runs: `scheduledFor` (one-shot) and/or `repeat` (recurring) turn this
|
|
1469
|
+
// request into a TICKET instead of a run. Parsed here so a bad time is a clean 400
|
|
1470
|
+
// before anything else; everything below still validates the request, so a schedule
|
|
1471
|
+
// fails fast now and is validated AGAIN when it starts.
|
|
1472
|
+
let sched = null;
|
|
1473
|
+
if (!internal && (body.scheduledFor != null || body.repeat != null || body.after != null || (body.sourceFromPrevious != null && body.sourceFromPrevious !== false))) {
|
|
1474
|
+
const parsed = parseScheduleRequest(body);
|
|
1475
|
+
if (!parsed.ok) return badRequest(res, parsed.error);
|
|
1476
|
+
sched = parsed;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1300
1479
|
// ── Shared resolution (factored BEFORE the target branch, §2.6) ──────────
|
|
1301
1480
|
// NEW (plugins §7.3): body.source is the task-source descriptor; shape-check
|
|
1302
1481
|
// only and pass through — the orchestrator resolves it exactly once. Absent
|
|
@@ -1373,6 +1552,16 @@ const startRunHandler = async (req, res) => {
|
|
|
1373
1552
|
const memoryScope = typeof body.memoryScope === 'string' && body.memoryScope.trim() ? body.memoryScope.trim() : null;
|
|
1374
1553
|
const scopeReason = validateMemoryScope({ workflowId, memoryScope, isWorkspace: !!hasWorkspace });
|
|
1375
1554
|
if (scopeReason) return badRequest(res, scopeReason);
|
|
1555
|
+
// Settings › Memory: a defragment run may name its model/effort PAIR at start — tier 1, above
|
|
1556
|
+
// the setting (memory-defrag-model.mjs). Defragment runs only: every other workflow picks its
|
|
1557
|
+
// models per node, and a body model there stays ignored exactly as before. The shape here; the
|
|
1558
|
+
// catalog check needs the target project (below).
|
|
1559
|
+
let startPair = null;
|
|
1560
|
+
if (memoryScope) {
|
|
1561
|
+
const shape = checkStartPair(body, null);
|
|
1562
|
+
if (shape.error) return badRequest(res, shape.error);
|
|
1563
|
+
startPair = shape.pair;
|
|
1564
|
+
}
|
|
1376
1565
|
// Human in the loop (spec D15): the body wins, else the project's stored
|
|
1377
1566
|
// switch, else on. Resolved per target below (it needs the project dir).
|
|
1378
1567
|
const bodyHumanInLoop = typeof body.humanInLoop === 'boolean' ? body.humanInLoop : null;
|
|
@@ -1395,16 +1584,20 @@ const startRunHandler = async (req, res) => {
|
|
|
1395
1584
|
|
|
1396
1585
|
// Budget gate: no new pipelines while the total window is spent (F6).
|
|
1397
1586
|
const budget = budgetStatus();
|
|
1398
|
-
if (budget.blocked) {
|
|
1587
|
+
if (budget.blocked && !sched) {
|
|
1399
1588
|
return res.status(403).json({ error: 'total cost limit reached', budget });
|
|
1400
1589
|
}
|
|
1401
1590
|
|
|
1402
|
-
const runId = randomUUID();
|
|
1591
|
+
const runId = internal ? internal.ticket.id : randomUUID();
|
|
1403
1592
|
const title = (typeof body.title === 'string' && body.title.trim()) || fallbackRunTitle(effectivePrompt, source);
|
|
1404
1593
|
|
|
1405
1594
|
// Materialize any uploaded extra files to a temp dir; the orchestrator's
|
|
1406
1595
|
// createPipeline copies them into <pipeline>/extras/.
|
|
1407
|
-
|
|
1596
|
+
// A ticket's extras were staged durably when it was scheduled (the OS temp dir
|
|
1597
|
+
// does not survive a reboot); a schedule stages them below, once it is validated.
|
|
1598
|
+
const extras = internal
|
|
1599
|
+
? (Array.isArray(stored.extrasPaths) ? stored.extrasPaths.filter((x) => typeof x === 'string' && fs.existsSync(x)) : [])
|
|
1600
|
+
: (sched ? [] : await writeExtras(runId, body.extras));
|
|
1408
1601
|
|
|
1409
1602
|
const branch = {
|
|
1410
1603
|
source: typeof body.sourceBranch === 'string' && body.sourceBranch.trim()
|
|
@@ -1425,6 +1618,12 @@ const startRunHandler = async (req, res) => {
|
|
|
1425
1618
|
const ws = await readWorkspace(workspaceId);
|
|
1426
1619
|
if (!ws) return res.status(404).json({ error: 'workspace not found' });
|
|
1427
1620
|
|
|
1621
|
+
// Team total cap (design §7): soft — `pastTeamCap` acknowledges it once per window per home.
|
|
1622
|
+
{
|
|
1623
|
+
const gate = await checkTeamTotalGate({ workspaceId: ws.id }, { pastTeamCap: body.pastTeamCap === true, reason: typeof body.policyReason === 'string' ? body.policyReason : null });
|
|
1624
|
+
if (gate.blocked) return res.status(gate.code === 'reason_required' ? 400 : 403).json({ error: gate.error, code: gate.code, policy: gate.policy, needsPolicyAck: gate.code === 'team_total' });
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1428
1627
|
// Resolve member detail. Each member must be an existing git repo (D3:
|
|
1429
1628
|
// per-project worktrees + checkpoints). A vanished member is a hard 400 —
|
|
1430
1629
|
// skip-missing is NOT allowed; a workspace run is defined over its full set.
|
|
@@ -1466,6 +1665,13 @@ const startRunHandler = async (req, res) => {
|
|
|
1466
1665
|
const wsFileProblem = await promptFileProblem(effectiveSource, projects[0].projectDir);
|
|
1467
1666
|
if (wsFileProblem) return badRequest(res, wsFileProblem);
|
|
1468
1667
|
|
|
1668
|
+
if (sched && sched.after) {
|
|
1669
|
+
const r = resolveAfterRef(sched.after, { workspaceId: ws.id, policy: sched.afterPolicy, isLive: liveProbe });
|
|
1670
|
+
if (!r.ok) return badRequest(res, r.error);
|
|
1671
|
+
sched.afterRef = r.after;
|
|
1672
|
+
}
|
|
1673
|
+
if (sched) return res.status(202).json(await scheduleRequest({ body, sched, title, askLink, budget, workspaceId: ws.id, projectDir: projects[0].projectDir }));
|
|
1674
|
+
|
|
1469
1675
|
orch = await createOrchestratorFor({
|
|
1470
1676
|
workspace: {
|
|
1471
1677
|
id: ws.id,
|
|
@@ -1483,7 +1689,9 @@ const startRunHandler = async (req, res) => {
|
|
|
1483
1689
|
template: workflowRow,
|
|
1484
1690
|
guardrailsId,
|
|
1485
1691
|
branch,
|
|
1486
|
-
claude: { permissionMode: 'acceptEdits', mock },
|
|
1692
|
+
claude: { permissionMode: stored.permissionMode || 'acceptEdits', ...(stored.model ? { model: stored.model } : {}), mock },
|
|
1693
|
+
// A CLI-made ticket may carry `--yes`: the explicit non-interactive choice survives the wait.
|
|
1694
|
+
...(stored.auto ? { auto: true } : {}),
|
|
1487
1695
|
});
|
|
1488
1696
|
|
|
1489
1697
|
entry = {
|
|
@@ -1522,14 +1730,35 @@ const startRunHandler = async (req, res) => {
|
|
|
1522
1730
|
|
|
1523
1731
|
const fileProblem = await promptFileProblem(effectiveSource, projectDir);
|
|
1524
1732
|
if (fileProblem) return badRequest(res, fileProblem);
|
|
1733
|
+
// The pair against THIS project's catalog (a schedule is checked here too, before it is
|
|
1734
|
+
// stored). A ticket firing takes its already-checked pair verbatim, like a CLI --model.
|
|
1735
|
+
if (startPair && !internal) {
|
|
1736
|
+
const checked = checkStartPair(body, await listModels(projectDir));
|
|
1737
|
+
if (checked.error) return badRequest(res, checked.error);
|
|
1738
|
+
startPair = checked.pair;
|
|
1739
|
+
}
|
|
1525
1740
|
// One live defragment run per scope (§7.3, amendment B20: this server process only).
|
|
1526
1741
|
if (memoryScope) {
|
|
1527
1742
|
const live = liveDefragRun(memoryScopeKey(memoryScope, projectDir));
|
|
1528
1743
|
if (live) return res.status(409).json({ error: 'a defragment run for this memory scope is already live', runId: live.id });
|
|
1529
1744
|
}
|
|
1530
1745
|
|
|
1746
|
+
// Team total cap (design §7): soft — `pastTeamCap` acknowledges it once per window per home.
|
|
1747
|
+
{
|
|
1748
|
+
const gate = await checkTeamTotalGate({ projectDir }, { pastTeamCap: body.pastTeamCap === true, reason: typeof body.policyReason === 'string' ? body.policyReason : null });
|
|
1749
|
+
if (gate.blocked) return res.status(gate.code === 'reason_required' ? 400 : 403).json({ error: gate.error, code: gate.code, policy: gate.policy, needsPolicyAck: gate.code === 'team_total' });
|
|
1750
|
+
}
|
|
1531
1751
|
const humanInLoop = bodyHumanInLoop ?? ((await readRunConfig(projectDir)).humanInLoop !== false);
|
|
1532
1752
|
|
|
1753
|
+
if (sched && sched.after) {
|
|
1754
|
+
const r = resolveAfterRef(sched.after, { projectDir, policy: sched.afterPolicy, isLive: liveProbe });
|
|
1755
|
+
if (!r.ok) return badRequest(res, r.error);
|
|
1756
|
+
sched.afterRef = r.after;
|
|
1757
|
+
}
|
|
1758
|
+
// A schedule stores the pair as checked (the catalog's casing, trimmed): its ticket takes it verbatim.
|
|
1759
|
+
const storedBody = startPair ? { ...body, model: startPair.model, effort: startPair.effort || undefined } : body;
|
|
1760
|
+
if (sched) return res.status(202).json(await scheduleRequest({ body: storedBody, sched, title, askLink, budget, projectDir }));
|
|
1761
|
+
|
|
1533
1762
|
orch = await createOrchestratorFor({
|
|
1534
1763
|
projectDir,
|
|
1535
1764
|
prompt: effectivePrompt,
|
|
@@ -1543,7 +1772,13 @@ const startRunHandler = async (req, res) => {
|
|
|
1543
1772
|
branch,
|
|
1544
1773
|
humanInLoop,
|
|
1545
1774
|
...(memoryScope ? { memoryScope } : {}),
|
|
1546
|
-
claude: {
|
|
1775
|
+
claude: {
|
|
1776
|
+
permissionMode: stored.permissionMode || 'acceptEdits',
|
|
1777
|
+
...(startPair ? { model: startPair.model, ...(startPair.effort ? { effort: startPair.effort } : {}) } : (stored.model ? { model: stored.model } : {})),
|
|
1778
|
+
mock,
|
|
1779
|
+
},
|
|
1780
|
+
// A CLI-made ticket may carry `--yes`: the explicit non-interactive choice survives the wait.
|
|
1781
|
+
...(stored.auto ? { auto: true } : {}),
|
|
1547
1782
|
});
|
|
1548
1783
|
|
|
1549
1784
|
entry = {
|
|
@@ -1559,6 +1794,11 @@ const startRunHandler = async (req, res) => {
|
|
|
1559
1794
|
};
|
|
1560
1795
|
}
|
|
1561
1796
|
|
|
1797
|
+
if (internal) {
|
|
1798
|
+
entry.ticketId = internal.ticket.id;
|
|
1799
|
+
entry.scheduleId = internal.ticket.scheduleId || null;
|
|
1800
|
+
entry.scheduledFor = internal.ticket.runAt;
|
|
1801
|
+
}
|
|
1562
1802
|
runs.set(runId, entry);
|
|
1563
1803
|
wireRun(entry);
|
|
1564
1804
|
if (askLink) {
|
|
@@ -1568,7 +1808,8 @@ const startRunHandler = async (req, res) => {
|
|
|
1568
1808
|
// must not launch a second pipeline for the same card (review of PR #376).
|
|
1569
1809
|
// Withdraw the run entry (nothing has run or been announced yet) and 409.
|
|
1570
1810
|
const still = askFindCard(askLink.threadId, askLink.cardId);
|
|
1571
|
-
|
|
1811
|
+
const stillOwn = internal && still && still.block.state === 'scheduled' && still.block.runId === runId;
|
|
1812
|
+
if (!still || (still.block.state !== 'proposed' && !stillOwn)) {
|
|
1572
1813
|
runs.delete(runId);
|
|
1573
1814
|
return res.status(409).json({ error: `card is no longer proposed (${still ? still.block.state : 'gone'})` });
|
|
1574
1815
|
}
|
|
@@ -1622,6 +1863,582 @@ const startRunHandler = async (req, res) => {
|
|
|
1622
1863
|
};
|
|
1623
1864
|
app.post('/api/run', startRunHandler);
|
|
1624
1865
|
|
|
1866
|
+
// ---------------------------------------------------------------------------
|
|
1867
|
+
// Scheduled runs (schema v31, src/core/scheduler.mjs). A schedule is a TICKET, not a
|
|
1868
|
+
// pipeline: POST /api/run with `scheduledFor` and/or `repeat` validates the request
|
|
1869
|
+
// exactly like a run, stores it, and answers 202. The tick below claims due tickets
|
|
1870
|
+
// and pushes each one back through startRunHandler — so a scheduled run is validated
|
|
1871
|
+
// twice and born through the one start path every run uses.
|
|
1872
|
+
// ---------------------------------------------------------------------------
|
|
1873
|
+
const TRANSIENT_SOURCE_KINDS = new Set(['network', 'rate-limit', 'timeout']);
|
|
1874
|
+
const SCHEDULER_TICK_MS = 30_000;
|
|
1875
|
+
const SCHEDULER_STAGGER_MS = 3_000;
|
|
1876
|
+
|
|
1877
|
+
/** Validate `scheduledFor` / `repeat` / `ifMissed` / `graceMin` on a run body. */
|
|
1878
|
+
function parseScheduleRequest(body, { now = Date.now() } = {}) {
|
|
1879
|
+
const defaults = scheduleDefaults();
|
|
1880
|
+
const out = { ok: true, runAtMs: null, repeat: null, after: null, afterPolicy: 'done', sourceFromPrevious: false, ifMissed: defaults.ifMissed, graceMin: defaults.graceMin };
|
|
1881
|
+
const given = ['scheduledFor', 'repeat', 'after'].filter((k) => body[k] != null);
|
|
1882
|
+
if (given.length > 1) return { ok: false, error: 'provide scheduledFor, repeat OR after, not both' };
|
|
1883
|
+
if (body.sourceFromPrevious != null && typeof body.sourceFromPrevious !== 'boolean') return { ok: false, error: 'sourceFromPrevious must be true or false' };
|
|
1884
|
+
if (body.sourceFromPrevious === true && body.after == null) return { ok: false, error: 'sourceFromPrevious needs after' };
|
|
1885
|
+
if (body.after != null) {
|
|
1886
|
+
const a = body.after;
|
|
1887
|
+
if (!a || typeof a !== 'object' || Array.isArray(a) || !['ticket', 'pipeline'].includes(a.kind) || typeof a.id !== 'string' || !a.id.trim()) {
|
|
1888
|
+
return { ok: false, error: 'after must be { kind: ticket | pipeline, id }' };
|
|
1889
|
+
}
|
|
1890
|
+
if (body.ifMissed != null || body.graceMin != null) return { ok: false, error: 'ifMissed and graceMin do not apply to a run after another run' };
|
|
1891
|
+
if (body.afterPolicy != null && !AFTER_POLICIES.includes(body.afterPolicy)) return { ok: false, error: `afterPolicy must be one of ${AFTER_POLICIES.join(' | ')}` };
|
|
1892
|
+
if (body.sourceFromPrevious === true && (body.sourceBranch != null || body.sourceBranchByKey != null)) return { ok: false, error: 'sourceFromPrevious and sourceBranch / sourceBranchByKey cannot both be given' };
|
|
1893
|
+
out.after = { kind: a.kind, id: a.id.trim() };
|
|
1894
|
+
out.afterPolicy = body.afterPolicy || 'done';
|
|
1895
|
+
out.sourceFromPrevious = body.sourceFromPrevious === true;
|
|
1896
|
+
return out;
|
|
1897
|
+
}
|
|
1898
|
+
if (body.ifMissed != null) {
|
|
1899
|
+
if (!MISSED_POLICIES.includes(body.ifMissed)) return { ok: false, error: `ifMissed must be one of ${MISSED_POLICIES.join(' | ')}` };
|
|
1900
|
+
out.ifMissed = body.ifMissed;
|
|
1901
|
+
}
|
|
1902
|
+
if (body.graceMin != null) {
|
|
1903
|
+
if (!Number.isSafeInteger(body.graceMin) || body.graceMin < 0 || body.graceMin > 10080) return { ok: false, error: 'graceMin must be a whole number of minutes from 0 to 10080' };
|
|
1904
|
+
out.graceMin = body.graceMin;
|
|
1905
|
+
}
|
|
1906
|
+
if (body.repeat != null) {
|
|
1907
|
+
if (body.scheduledFor != null) return { ok: false, error: 'provide scheduledFor (run once) OR repeat (recurring), not both' };
|
|
1908
|
+
const rep = body.repeat;
|
|
1909
|
+
if (!rep || typeof rep !== 'object' || Array.isArray(rep)) return { ok: false, error: 'repeat must be an object: { rule, overlap?, maxFailures? }' };
|
|
1910
|
+
const tz = rep.rule && typeof rep.rule === 'object' ? rep.rule.tz : null;
|
|
1911
|
+
const norm = normalizeRule(rep.rule, { todayLocal: isValidTimeZone(tz) ? localDate(now, tz) : null });
|
|
1912
|
+
if (!norm.ok) return { ok: false, error: norm.error };
|
|
1913
|
+
if (rep.overlap != null && !OVERLAP_POLICIES.includes(rep.overlap)) return { ok: false, error: `repeat.overlap must be one of ${OVERLAP_POLICIES.join(' | ')}` };
|
|
1914
|
+
if (rep.maxFailures != null && (!Number.isSafeInteger(rep.maxFailures) || rep.maxFailures < 0 || rep.maxFailures > 100)) {
|
|
1915
|
+
return { ok: false, error: 'repeat.maxFailures must be a whole number from 0 to 100 (0 = never pause)' };
|
|
1916
|
+
}
|
|
1917
|
+
if (nextOccurrence(norm.rule, now) == null) return { ok: false, error: 'repeat.rule has no future occurrence' };
|
|
1918
|
+
out.repeat = { rule: norm.rule, overlap: rep.overlap || 'skip', maxFailures: rep.maxFailures ?? defaults.maxFailures };
|
|
1919
|
+
return out;
|
|
1920
|
+
}
|
|
1921
|
+
const at = parseScheduledFor(body.scheduledFor);
|
|
1922
|
+
if (!at.ok) return { ok: false, error: at.error };
|
|
1923
|
+
if (at.ms < now - 5_000) return { ok: false, error: 'scheduledFor is in the past' };
|
|
1924
|
+
out.runAtMs = at.ms;
|
|
1925
|
+
return out;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/** The request a ticket stores: the validated body minus schedule fields and uploads. */
|
|
1929
|
+
async function storedRequestOf(body, stageId, projectDir) {
|
|
1930
|
+
const request = { ...body };
|
|
1931
|
+
for (const k of ['scheduledFor', 'repeat', 'after', 'afterPolicy', 'sourceFromPrevious', 'ifMissed', 'graceMin', 'extras', 'internal']) delete request[k];
|
|
1932
|
+
// Text the user authored is part of the request: a prompt FILE is frozen now, so a
|
|
1933
|
+
// file deleted or half-edited overnight cannot fail an unattended run.
|
|
1934
|
+
if (request.source && request.source.type === 'markdown' && request.source.promptFile && !request.source.promptText) {
|
|
1935
|
+
const promptText = await readPromptFile(projectDir, request.source.promptFile);
|
|
1936
|
+
request.source = { type: 'markdown', promptText };
|
|
1937
|
+
}
|
|
1938
|
+
const extrasPaths = await writeExtras(stageId, body.extras, path.join(scheduleStageDir(stageId), 'extras'));
|
|
1939
|
+
request.internal = { extrasPaths };
|
|
1940
|
+
return request;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/** Turn a validated run request into a ticket (or a recurring schedule). 202 body. */
|
|
1944
|
+
async function scheduleRequest({ body, sched, title, askLink, budget, projectDir, workspaceId = null }) {
|
|
1945
|
+
const target = workspaceId ? { workspaceId } : { projectDir };
|
|
1946
|
+
let ticket, schedule = null;
|
|
1947
|
+
if (sched.repeat) {
|
|
1948
|
+
const id = `sch_${randomBytes(4).toString('hex')}`;
|
|
1949
|
+
const request = await storedRequestOf(body, id, projectDir);
|
|
1950
|
+
({ schedule, ticket } = createSchedule({
|
|
1951
|
+
id, title, ...target, request, rule: sched.repeat.rule, overlap: sched.repeat.overlap,
|
|
1952
|
+
maxFailures: sched.repeat.maxFailures, ifMissed: sched.ifMissed, graceMin: sched.graceMin,
|
|
1953
|
+
askThreadId: askLink ? askLink.threadId : null, askCardId: askLink ? askLink.cardId : null,
|
|
1954
|
+
}));
|
|
1955
|
+
// An Ask card that became a repeating schedule follows the SERIES, not one run of it.
|
|
1956
|
+
if (askLink) {
|
|
1957
|
+
try { flipCard(askLink.threadId, askLink.cardId, { state: 'scheduled', runId: null, scheduleId: schedule.id, sentence: schedule.sentence, scheduledFor: ticket ? ticket.runAt : null }); }
|
|
1958
|
+
catch (err) { console.error(`[worca-ui] ask card schedule flip failed: ${err && err.message ? err.message : err}`); }
|
|
1959
|
+
}
|
|
1960
|
+
} else {
|
|
1961
|
+
const id = randomUUID();
|
|
1962
|
+
const request = await storedRequestOf(body, id, projectDir);
|
|
1963
|
+
// Both arms carry ifMissed / graceMin: parseScheduleRequest filled them with the Settings defaults
|
|
1964
|
+
// (an after body may not name them), and a chained ticket later moved to a time shows them.
|
|
1965
|
+
const chain = sched.after ? {
|
|
1966
|
+
after: { kind: sched.afterRef.kind, id: sched.afterRef.id }, afterPolicy: sched.afterPolicy, sourceFromPrevious: sched.sourceFromPrevious, ifMissed: sched.ifMissed, graceMin: sched.graceMin,
|
|
1967
|
+
} : { runAtMs: sched.runAtMs, ifMissed: sched.ifMissed, graceMin: sched.graceMin };
|
|
1968
|
+
ticket = createTicket({ id, title, ...target, request, ...chain, askThreadId: askLink ? askLink.threadId : null, askCardId: askLink ? askLink.cardId : null });
|
|
1969
|
+
if (askLink) {
|
|
1970
|
+
try { flipCard(askLink.threadId, askLink.cardId, { state: 'scheduled', runId: id, scheduledFor: sched.after ? null : ticket.runAt, after: sched.after ? { kind: sched.afterRef.kind, id: sched.afterRef.id, title: sched.afterRef.title } : null }); }
|
|
1971
|
+
catch (err) { console.error(`[worca-ui] ask card schedule flip failed: ${err && err.message ? err.message : err}`); }
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
emitChanged('schedules-changed', 'created');
|
|
1975
|
+
return {
|
|
1976
|
+
runId: ticket ? ticket.id : null,
|
|
1977
|
+
status: 'scheduled',
|
|
1978
|
+
scheduledFor: ticket && !ticket.after ? ticket.runAt : null,
|
|
1979
|
+
...(ticket && ticket.after ? { after: { kind: sched.afterRef.kind, id: sched.afterRef.id, title: sched.afterRef.title }, sourceFromPrevious: ticket.sourceFromPrevious } : {}),
|
|
1980
|
+
...(schedule ? { scheduleId: schedule.id, sentence: schedule.sentence } : {}),
|
|
1981
|
+
...(budget && budget.blocked ? { budgetWarning: 'The total cost limit is reached right now. The run will only start if the budget allows it at that time.' } : {}),
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
|
|
1985
|
+
/** An Ask card that was scheduled goes back to `proposed` when its ticket dies or its series is deleted. */
|
|
1986
|
+
function releaseAskCard(item) {
|
|
1987
|
+
if (!item || !item.askThreadId || !item.askCardId) return;
|
|
1988
|
+
try {
|
|
1989
|
+
const found = askFindCard(item.askThreadId, item.askCardId);
|
|
1990
|
+
const b = found && found.block;
|
|
1991
|
+
if (b && b.state === 'scheduled' && (item.kind === 'recurring' ? b.scheduleId === item.id : b.runId === item.id)) {
|
|
1992
|
+
flipCard(item.askThreadId, item.askCardId, { state: 'proposed', runId: null, scheduledFor: null, scheduleId: null, sentence: null });
|
|
1993
|
+
}
|
|
1994
|
+
} catch (err) { console.error(`[worca-ui] ask card release failed: ${err && err.message ? err.message : err}`); }
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
/** Call startRunHandler without HTTP. Resolves { status, body }. */
|
|
1998
|
+
async function invokeStartRun(body, internal) {
|
|
1999
|
+
let out = { status: 200, body: null };
|
|
2000
|
+
const res = {
|
|
2001
|
+
statusCode: 200,
|
|
2002
|
+
status(code) { this.statusCode = code; return this; },
|
|
2003
|
+
json(payload) { out = { status: this.statusCode, body: payload }; return this; },
|
|
2004
|
+
};
|
|
2005
|
+
await startRunHandler({ body, _internal: internal }, res);
|
|
2006
|
+
return out;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/** runDueTickets' `start`: probe an external task first (transient errors retry), then start. */
|
|
2010
|
+
async function fireTicket(ticket) {
|
|
2011
|
+
const body = { ...(ticket.request || {}) };
|
|
2012
|
+
if (body.source && body.source.type === 'plugin') {
|
|
2013
|
+
try {
|
|
2014
|
+
await resolveTaskInput(body.source, { projectDir: ticket.projectDir || undefined });
|
|
2015
|
+
} catch (err) {
|
|
2016
|
+
return { ok: false, error: err && err.message ? err.message : String(err), transient: TRANSIENT_SOURCE_KINDS.has(err && err.kind) };
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
if (ticket.after) {
|
|
2020
|
+
const p = predecessorState(ticket.after, { policy: ticket.after.policy, isLive: liveProbe });
|
|
2021
|
+
if (p.state === 'waiting' && !ticket.forced) return { ok: false, error: 'the run before it is still going', transient: true };
|
|
2022
|
+
if (ticket.sourceFromPrevious) {
|
|
2023
|
+
const prev = p.pipelineId ? previousBranchesOf(p.pipelineId) : null;
|
|
2024
|
+
if (!prev) return { ok: false, error: 'the run before it left no branch to start from', transient: false };
|
|
2025
|
+
if (prev.sourceBranch) {
|
|
2026
|
+
if (!(await isValidSourceRef(ticket.projectDir, prev.sourceBranch))) return { ok: false, error: `branch ${prev.sourceBranch} no longer exists`, transient: false };
|
|
2027
|
+
} else {
|
|
2028
|
+
const ws = ticket.workspaceId ? await readWorkspace(ticket.workspaceId) : null;
|
|
2029
|
+
if (!ws) return { ok: false, error: 'workspace not found', transient: false };
|
|
2030
|
+
for (const dir of ws.projectPaths) {
|
|
2031
|
+
const key = projectKey(dir);
|
|
2032
|
+
const br = prev.sourceBranchByKey[key];
|
|
2033
|
+
if (!br) return { ok: false, error: `the run before it has no branch for ${path.basename(dir)}`, transient: false };
|
|
2034
|
+
if (!(await isValidSourceRef(dir, br))) return { ok: false, error: `branch ${br} no longer exists in ${path.basename(dir)}`, transient: false };
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
delete body.sourceBranch; delete body.sourceBranchByKey;
|
|
2038
|
+
Object.assign(body, prev);
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
// Every occurrence of a series needs its own feature branch.
|
|
2042
|
+
if (ticket.scheduleId && typeof body.featureBranch === 'string' && body.featureBranch.trim()) {
|
|
2043
|
+
const s = getSchedule(ticket.scheduleId);
|
|
2044
|
+
const day = (s && s.tz ? localDate(Date.parse(ticket.runAt), s.tz) : ticket.runAt.slice(0, 10)).replace(/-/g, '');
|
|
2045
|
+
body.featureBranch = `${body.featureBranch.trim()}-${day}`;
|
|
2046
|
+
}
|
|
2047
|
+
const out = await invokeStartRun(body, { ticket });
|
|
2048
|
+
if (out.status === 200 && out.body && out.body.runId) return { ok: true };
|
|
2049
|
+
const error = (out.body && out.body.error) || `the run could not be started (HTTP ${out.status})`;
|
|
2050
|
+
return { ok: false, error, transient: out.status >= 500 };
|
|
2051
|
+
}
|
|
2052
|
+
|
|
2053
|
+
/** The host's in-memory view of a run: by run id, or by the pipeline id it became. liveRunEntry is the
|
|
2054
|
+
* house lookup: it skips scans / agentgens / benches and, on a resumed lineage (D23), prefers the entry
|
|
2055
|
+
* still driving the pipeline over a settled same-pipeline entry that sits earlier in Map order. */
|
|
2056
|
+
function liveProbe({ id, pipelineId }) {
|
|
2057
|
+
const e = liveRunEntry(id) || (pipelineId ? liveRunEntry(pipelineId) : null);
|
|
2058
|
+
return !!e && !SETTLED_RUN.has(String(e.status || ''));
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
let _schedulerBusy = false;
|
|
2062
|
+
let _lastNotificationId = -1;
|
|
2063
|
+
let _lastScheduleSig = null;
|
|
2064
|
+
|
|
2065
|
+
/** One scheduler pass. Exported for tests; the server calls it on a 30 s timer. */
|
|
2066
|
+
export async function schedulerTick({ now = Date.now() } = {}) {
|
|
2067
|
+
if (_schedulerBusy) return null;
|
|
2068
|
+
_schedulerBusy = true;
|
|
2069
|
+
try {
|
|
2070
|
+
const out = await runDueTickets({
|
|
2071
|
+
now,
|
|
2072
|
+
start: fireTicket,
|
|
2073
|
+
isLive: liveProbe,
|
|
2074
|
+
staggerMs: SCHEDULER_STAGGER_MS,
|
|
2075
|
+
});
|
|
2076
|
+
for (const id of [...out.failed, ...out.missed]) releaseAskCard(getTicket(id));
|
|
2077
|
+
if (out.fired.length || out.missed.length || out.skipped.length || out.failed.length || out.retried.length) {
|
|
2078
|
+
emitChanged('schedules-changed', 'tick');
|
|
2079
|
+
}
|
|
2080
|
+
// A `--wait` CLI writes notifications from ITS process: notice them here.
|
|
2081
|
+
const latest = latestNotificationId();
|
|
2082
|
+
if (_lastNotificationId !== -1 && latest !== _lastNotificationId) emitChanged('notifications-changed');
|
|
2083
|
+
_lastNotificationId = latest;
|
|
2084
|
+
// ...and the CLI writes tickets straight into the shared DB: notice those too.
|
|
2085
|
+
const sig = scheduleSignature();
|
|
2086
|
+
if (_lastScheduleSig !== null && sig !== _lastScheduleSig && !out.fired.length) emitChanged('schedules-changed', 'external');
|
|
2087
|
+
_lastScheduleSig = sig;
|
|
2088
|
+
return out;
|
|
2089
|
+
} catch (err) {
|
|
2090
|
+
console.error(`[worca-ui] scheduler tick failed: ${err && err.message ? err.message : err}`);
|
|
2091
|
+
return null;
|
|
2092
|
+
} finally {
|
|
2093
|
+
_schedulerBusy = false;
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
let _schedulerTimer = null;
|
|
2098
|
+
/** Start the scheduler: recover, run one pass now (boot catch-up), then tick. */
|
|
2099
|
+
export function startScheduler() {
|
|
2100
|
+
if (_schedulerTimer) return () => {};
|
|
2101
|
+
try {
|
|
2102
|
+
const recovered = recoverScheduler();
|
|
2103
|
+
if (recovered) console.log(`[worca-ui] scheduler: returned ${recovered} interrupted ticket(s) to the queue`);
|
|
2104
|
+
const purged = purgeScheduler();
|
|
2105
|
+
purgeNotifications();
|
|
2106
|
+
if (purged.tickets || purged.schedules) console.log(`[worca-ui] scheduler: purged ${purged.tickets} old ticket(s), ${purged.schedules} ended schedule(s)`);
|
|
2107
|
+
} catch (err) { console.error(`[worca-ui] scheduler recovery failed: ${err && err.message ? err.message : err}`); }
|
|
2108
|
+
schedulerTick();
|
|
2109
|
+
_schedulerTimer = setInterval(() => { schedulerTick(); }, SCHEDULER_TICK_MS);
|
|
2110
|
+
_schedulerTimer.unref();
|
|
2111
|
+
return () => { clearInterval(_schedulerTimer); _schedulerTimer = null; };
|
|
2112
|
+
}
|
|
2113
|
+
|
|
2114
|
+
// Notifications written in THIS process reach open tabs at once, and chat.
|
|
2115
|
+
onNotification((n) => {
|
|
2116
|
+
broadcast({ type: 'notification', notification: n });
|
|
2117
|
+
try { chatNotifier.notifySchedule(n); } catch { /* never break the writer */ }
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
/** A schedule item (ticket or series) by id, for the unified /api/schedules routes. */
|
|
2121
|
+
function findScheduleItem(id) {
|
|
2122
|
+
if (typeof id !== 'string' || !id) return null;
|
|
2123
|
+
if (id.startsWith('sch_')) { const s = getSchedule(id); return s ? { kind: 'recurring', item: s } : null; }
|
|
2124
|
+
const t = getTicket(id);
|
|
2125
|
+
return t ? { kind: 'once', item: t } : null;
|
|
2126
|
+
}
|
|
2127
|
+
|
|
2128
|
+
/** A ticket for the wire: its predecessor resolved for display (title, live status, pipeline). */
|
|
2129
|
+
function withAfter(t) {
|
|
2130
|
+
if (!t || !t.after) return t;
|
|
2131
|
+
const p = predecessorState(t.after, { policy: t.after.policy, isLive: liveProbe });
|
|
2132
|
+
return { ...t, after: { ...t.after, title: p.title || null, status: p.status || null, pipelineId: p.pipelineId || null } };
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// GET /api/schedules[?projectDir=|workspaceId=][&all=1] -> { schedules, tickets, counts, defaults }
|
|
2136
|
+
app.get('/api/schedules', (req, res) => {
|
|
2137
|
+
try {
|
|
2138
|
+
const projectDir = resolveProjectDir(req.query.projectDir) || null;
|
|
2139
|
+
const workspaceId = typeof req.query.workspaceId === 'string' && req.query.workspaceId.trim() ? req.query.workspaceId.trim() : null;
|
|
2140
|
+
const all = req.query.all === '1' || req.query.all === 'true';
|
|
2141
|
+
res.json({
|
|
2142
|
+
schedules: listSchedules({ projectDir, workspaceId }),
|
|
2143
|
+
tickets: listTickets({ projectDir, workspaceId, all }).map(withAfter),
|
|
2144
|
+
counts: { ...scheduleCounts(), unread: unreadCount('schedule') },
|
|
2145
|
+
defaults: scheduleDefaults(),
|
|
2146
|
+
});
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2149
|
+
}
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
// POST /api/schedules/preview { rule, count? } -> { sentence, next: [iso…] }
|
|
2153
|
+
app.post('/api/schedules/preview', (req, res) => {
|
|
2154
|
+
const body = req.body || {};
|
|
2155
|
+
const tz = body.rule && typeof body.rule === 'object' ? body.rule.tz : null;
|
|
2156
|
+
const norm = normalizeRule(body.rule, { todayLocal: isValidTimeZone(tz) ? localDate(Date.now(), tz) : null });
|
|
2157
|
+
if (!norm.ok) return badRequest(res, norm.error);
|
|
2158
|
+
const n = Number.isSafeInteger(body.count) ? Math.max(1, Math.min(10, body.count)) : 3;
|
|
2159
|
+
res.json({ rule: norm.rule, sentence: describeRule(norm.rule), next: previewOccurrences(norm.rule, Date.now(), n).map((t) => new Date(t).toISOString()) });
|
|
2160
|
+
});
|
|
2161
|
+
|
|
2162
|
+
// GET /api/schedules/dependents?workflowId=|projectDir=|workspaceId= -> what a removal
|
|
2163
|
+
// would strand, so the confirmation can NAME it.
|
|
2164
|
+
app.get('/api/schedules/dependents', (req, res) => {
|
|
2165
|
+
const q = req.query;
|
|
2166
|
+
const label = (x) => ({ id: x.id, kind: x.kind, title: x.title });
|
|
2167
|
+
if (typeof q.workflowId === 'string' && q.workflowId) return res.json({ dependents: dependentsOfWorkflow(q.workflowId) });
|
|
2168
|
+
if (typeof q.pipelineId === 'string' && q.pipelineId) return res.json({ dependents: dependentsOfRun({ pipelineId: q.pipelineId }) });
|
|
2169
|
+
if (typeof q.ticketId === 'string' && q.ticketId) return res.json({ dependents: dependentsOfRun({ ticketId: q.ticketId }) });
|
|
2170
|
+
const projectDir = resolveProjectDir(q.projectDir) || null;
|
|
2171
|
+
const workspaceId = typeof q.workspaceId === 'string' && q.workspaceId.trim() ? q.workspaceId.trim() : null;
|
|
2172
|
+
if (!projectDir && !workspaceId) return badRequest(res, 'workflowId, projectDir or workspaceId is required');
|
|
2173
|
+
res.json({
|
|
2174
|
+
dependents: [
|
|
2175
|
+
...listSchedules({ projectDir, workspaceId, includeEnded: false }).map(label),
|
|
2176
|
+
...listTickets({ projectDir, workspaceId, oneShotOnly: true }).map(label),
|
|
2177
|
+
],
|
|
2178
|
+
});
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
// GET /api/schedules/after-candidates?projectDir=|workspaceId= -> what a new run may wait for:
|
|
2182
|
+
// live runs of that target and its one-off tickets that have not ended.
|
|
2183
|
+
app.get('/api/schedules/after-candidates', (req, res) => {
|
|
2184
|
+
try {
|
|
2185
|
+
const projectDir = resolveProjectDir(req.query.projectDir) || null;
|
|
2186
|
+
const workspaceId = typeof req.query.workspaceId === 'string' && req.query.workspaceId.trim() ? req.query.workspaceId.trim() : null;
|
|
2187
|
+
if (!projectDir && !workspaceId) return badRequest(res, 'projectDir or workspaceId is required');
|
|
2188
|
+
// The ROWS are the source of truth (spec §5): SETTLED_RUN contains 'paused', and another
|
|
2189
|
+
// process's run is not in this host's runs Map at all. The Map only adds live runIds.
|
|
2190
|
+
const where = workspaceId ? 'workspace_key = ?' : "project_key = ? AND target = 'project'";
|
|
2191
|
+
const rows = getDb().prepare(`SELECT id, title, status FROM pipelines WHERE ${where}
|
|
2192
|
+
AND status IN ('created', 'starting', 'running', 'pausing', 'paused') AND archived_at IS NULL ORDER BY started_at DESC LIMIT 50`)
|
|
2193
|
+
.all(workspaceId || projectKey(projectDir));
|
|
2194
|
+
const byPipeline = new Map(rows.map((r) => [r.id, { pipelineId: r.id, runId: null, title: r.title || null, status: r.status }]));
|
|
2195
|
+
for (const r of runs.values()) {
|
|
2196
|
+
if (!r.pipelineId || (r.kind && r.kind !== 'run' && r.kind !== 'workspace-run')) continue;
|
|
2197
|
+
if (workspaceId ? r.workspaceId !== workspaceId : (r.projectDir !== projectDir || r.workspaceId)) continue;
|
|
2198
|
+
const stillGoing = !SETTLED_RUN.has(String(r.status || '')) || r.status === 'paused';
|
|
2199
|
+
if (!stillGoing) continue;
|
|
2200
|
+
const cur = byPipeline.get(r.pipelineId) || { pipelineId: r.pipelineId, title: r.title || null, status: r.status };
|
|
2201
|
+
byPipeline.set(r.pipelineId, { ...cur, runId: r.id, status: r.status });
|
|
2202
|
+
}
|
|
2203
|
+
// listTickets({ oneShotOnly: true }) returns scheduled | firing | missed. A MISSED ticket is left
|
|
2204
|
+
// out: resolveAfterRef refuses it under either policy ("‘X’ was missed — nothing to wait for"),
|
|
2205
|
+
// so offering it would be a dead pick (spec §5 lists it; this is the one deliberate narrowing).
|
|
2206
|
+
const tickets = listTickets({ projectDir, workspaceId, oneShotOnly: true })
|
|
2207
|
+
.filter((t) => t.status !== 'missed')
|
|
2208
|
+
.map((t) => ({ id: t.id, title: t.title, status: t.status, after: t.after ? { kind: t.after.kind, id: t.after.id } : null }));
|
|
2209
|
+
res.json({ runs: [...byPipeline.values()], tickets });
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2212
|
+
}
|
|
2213
|
+
});
|
|
2214
|
+
|
|
2215
|
+
// GET /api/schedules/after/:id -> one predecessor and its target (the #new/after/<id> deep link).
|
|
2216
|
+
// async (listProjects is async) — and therefore wrapped: Express 4 does not catch a rejected
|
|
2217
|
+
// handler, and the deep link's fetch would hang instead of showing an error line.
|
|
2218
|
+
app.get('/api/schedules/after/:id', async (req, res) => {
|
|
2219
|
+
try {
|
|
2220
|
+
const id = req.params.id;
|
|
2221
|
+
if (id.startsWith('sch_')) return badRequest(res, 'after a repeating schedule is not supported — give the id of one of its runs');
|
|
2222
|
+
const ref = afterRefOf(id);
|
|
2223
|
+
if (!ref || ref.scheduleId) return res.status(404).json({ error: 'run not found' });
|
|
2224
|
+
let projectDir = null;
|
|
2225
|
+
if (ref.kind === 'ticket') projectDir = (getTicket(ref.id) || {}).projectDir || null; // a purge between the two reads is a null, not a throw
|
|
2226
|
+
else if (!ref.workspaceId) {
|
|
2227
|
+
const projects = await listProjects();
|
|
2228
|
+
projectDir = (projects.find((p) => projectKey(p.path) === ref.projectKey) || {}).path || null;
|
|
2229
|
+
}
|
|
2230
|
+
res.json({ kind: ref.kind, id: ref.id, title: ref.title, status: ref.status, projectDir, workspaceId: ref.workspaceId || null });
|
|
2231
|
+
} catch (err) {
|
|
2232
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2233
|
+
}
|
|
2234
|
+
});
|
|
2235
|
+
|
|
2236
|
+
app.get('/api/schedules/:id', (req, res) => {
|
|
2237
|
+
const found = findScheduleItem(req.params.id);
|
|
2238
|
+
if (!found) return res.status(404).json({ error: 'schedule not found' });
|
|
2239
|
+
const history = found.kind === 'recurring' ? listTickets({ scheduleId: found.item.id, all: true, limit: 50 }).reverse() : [];
|
|
2240
|
+
res.json({ ...found, item: found.kind === 'once' ? withAfter(found.item) : found.item, history, notifications: listNotifications({ scheduleId: found.kind === 'recurring' ? found.item.id : null, limit: 50 }).filter((n) => found.kind === 'recurring' || n.ticketId === found.item.id) });
|
|
2241
|
+
});
|
|
2242
|
+
|
|
2243
|
+
// One schedule change, for the REST routes AND an applied Ask Worca schedule card — so the
|
|
2244
|
+
// card does exactly what the button on the Schedules page does. Resolves { status, body }.
|
|
2245
|
+
// verb 'patch' a ticket: { scheduledFor?, ifMissed?, graceMin? }
|
|
2246
|
+
// a series: { title?, rule?, overlap?, maxFailures?, ifMissed?, graceMin? }
|
|
2247
|
+
// verb 'delete' cancel a one-off ticket, or delete a series
|
|
2248
|
+
// verb 'run-now' start a ticket now, or one extra occurrence of a series
|
|
2249
|
+
// verb 'pause' | 'resume' | 'skip-next' a series only
|
|
2250
|
+
async function scheduleVerb(verb, id, body = {}) {
|
|
2251
|
+
const found = findScheduleItem(id);
|
|
2252
|
+
const out = (status, payload) => ({ status, body: payload });
|
|
2253
|
+
if (!found) return out(404, { error: 'schedule not found' });
|
|
2254
|
+
if (verb === 'patch') {
|
|
2255
|
+
try {
|
|
2256
|
+
if (found.kind === 'once') {
|
|
2257
|
+
const patch = {};
|
|
2258
|
+
if (body.scheduledFor != null) {
|
|
2259
|
+
const at = parseScheduledFor(body.scheduledFor);
|
|
2260
|
+
if (!at.ok) return out(400, { error: at.error });
|
|
2261
|
+
if (at.ms < Date.now() - 5_000) return out(400, { error: 'scheduledFor is in the past' });
|
|
2262
|
+
patch.runAtMs = at.ms;
|
|
2263
|
+
}
|
|
2264
|
+
if (body.ifMissed != null) {
|
|
2265
|
+
if (!MISSED_POLICIES.includes(body.ifMissed)) return out(400, { error: `ifMissed must be one of ${MISSED_POLICIES.join(' | ')}` });
|
|
2266
|
+
patch.ifMissed = body.ifMissed;
|
|
2267
|
+
}
|
|
2268
|
+
if (body.graceMin != null) patch.graceMin = body.graceMin;
|
|
2269
|
+
if (body.after != null) {
|
|
2270
|
+
if (body.scheduledFor != null) return out(400, { error: 'provide scheduledFor OR after, not both' });
|
|
2271
|
+
if (body.ifMissed != null || body.graceMin != null) return out(400, { error: 'ifMissed and graceMin do not apply to a run after another run' });
|
|
2272
|
+
const policy = body.afterPolicy != null ? body.afterPolicy : (found.item.after ? found.item.after.policy : 'done');
|
|
2273
|
+
if (!AFTER_POLICIES.includes(policy)) return out(400, { error: `afterPolicy must be one of ${AFTER_POLICIES.join(' | ')}` });
|
|
2274
|
+
const r = resolveAfterRef(body.after, { projectDir: found.item.projectDir, workspaceId: found.item.workspaceId, policy, selfId: found.item.id, isLive: liveProbe });
|
|
2275
|
+
if (!r.ok) return out(400, { error: r.error });
|
|
2276
|
+
patch.after = { kind: r.after.kind, id: r.after.id };
|
|
2277
|
+
patch.afterPolicy = policy;
|
|
2278
|
+
} else if (body.afterPolicy != null) {
|
|
2279
|
+
if (!AFTER_POLICIES.includes(body.afterPolicy)) return out(400, { error: `afterPolicy must be one of ${AFTER_POLICIES.join(' | ')}` });
|
|
2280
|
+
// A timed ticket has no policy (updateTicket drops one sent with runAtMs); say so rather than accept and
|
|
2281
|
+
// ignore — for a policy sent WITH a time, and for one sent ALONE to a ticket that has no predecessor
|
|
2282
|
+
// (that wrote after_policy onto a row with after_id NULL: harmless, but a lie in the row).
|
|
2283
|
+
if (patch.runAtMs != null || !found.item.after) return out(400, { error: 'afterPolicy does not apply to a run at a time' });
|
|
2284
|
+
patch.afterPolicy = body.afterPolicy;
|
|
2285
|
+
}
|
|
2286
|
+
if (body.sourceFromPrevious != null) {
|
|
2287
|
+
if (typeof body.sourceFromPrevious !== 'boolean') return out(400, { error: 'sourceFromPrevious must be true or false' });
|
|
2288
|
+
// Needs a predecessor AFTER this patch: none on the row and none coming, or a move back to a time
|
|
2289
|
+
// (which clears after_* — `source_from_previous = 1` with `after_id = NULL` must never be written).
|
|
2290
|
+
if (body.sourceFromPrevious && (patch.runAtMs != null || (!patch.after && !found.item.after))) return out(400, { error: 'sourceFromPrevious needs after' });
|
|
2291
|
+
patch.sourceFromPrevious = body.sourceFromPrevious;
|
|
2292
|
+
}
|
|
2293
|
+
if (found.item.scheduleId && (patch.runAtMs != null || patch.after)) return out(400, { error: 'an occurrence of a repeating schedule cannot be moved — edit the schedule, or skip this occurrence' });
|
|
2294
|
+
const t = updateTicket(found.item.id, patch);
|
|
2295
|
+
if (!t) return out(409, { error: `this run is ${found.item.status} and can no longer be changed` });
|
|
2296
|
+
const item = withAfter(t);
|
|
2297
|
+
if (t.askThreadId && t.askCardId && (patch.runAtMs != null || patch.after)) {
|
|
2298
|
+
try { flipCard(t.askThreadId, t.askCardId, { scheduledFor: t.after ? null : t.runAt, after: t.after ? { kind: t.after.kind, id: t.after.id, title: item.after.title } : null }); } catch { /* display only */ }
|
|
2299
|
+
}
|
|
2300
|
+
emitChanged('schedules-changed', 'updated');
|
|
2301
|
+
emitChanged('notifications-changed');
|
|
2302
|
+
return out(200, { kind: 'once', item });
|
|
2303
|
+
}
|
|
2304
|
+
const patch = {};
|
|
2305
|
+
for (const k of ['title', 'rule', 'overlap', 'maxFailures', 'ifMissed', 'graceMin']) if (body[k] !== undefined) patch[k] = body[k];
|
|
2306
|
+
if (patch.rule && typeof patch.rule === 'object' && !isValidTimeZone(patch.rule.tz)) return out(400, { error: `rule.tz is not a known timezone: ${patch.rule.tz ?? '(missing)'}` });
|
|
2307
|
+
if (patch.maxFailures !== undefined && (!Number.isSafeInteger(patch.maxFailures) || patch.maxFailures < 0 || patch.maxFailures > 100)) {
|
|
2308
|
+
return out(400, { error: 'maxFailures must be a whole number from 0 to 100 (0 = never pause)' });
|
|
2309
|
+
}
|
|
2310
|
+
if (patch.graceMin !== undefined && (!Number.isSafeInteger(patch.graceMin) || patch.graceMin < 0 || patch.graceMin > 10080)) {
|
|
2311
|
+
return out(400, { error: 'graceMin must be a whole number of minutes from 0 to 10080' });
|
|
2312
|
+
}
|
|
2313
|
+
const s = updateSchedule(found.item.id, patch);
|
|
2314
|
+
if (s && s.askThreadId && s.askCardId && patch.rule) {
|
|
2315
|
+
try { flipCard(s.askThreadId, s.askCardId, { sentence: s.sentence, scheduledFor: s.nextRunAt }); } catch { /* display only */ }
|
|
2316
|
+
}
|
|
2317
|
+
emitChanged('schedules-changed', 'updated');
|
|
2318
|
+
return out(200, { kind: 'recurring', item: s });
|
|
2319
|
+
} catch (err) {
|
|
2320
|
+
return out(400, { error: err && err.message ? err.message : String(err) });
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
if (verb === 'delete') {
|
|
2324
|
+
if (found.kind === 'recurring') {
|
|
2325
|
+
deleteSchedule(found.item.id);
|
|
2326
|
+
releaseAskCard(found.item);
|
|
2327
|
+
} else {
|
|
2328
|
+
if (found.item.scheduleId) return out(400, { error: 'this is an occurrence of a repeating schedule — skip it instead' });
|
|
2329
|
+
const t = cancelTicket(found.item.id);
|
|
2330
|
+
if (!t) return out(409, { error: `this run is ${found.item.status} and can no longer be canceled` });
|
|
2331
|
+
releaseAskCard(t);
|
|
2332
|
+
}
|
|
2333
|
+
emitChanged('schedules-changed', 'deleted');
|
|
2334
|
+
emitChanged('notifications-changed');
|
|
2335
|
+
return out(200, { ok: true });
|
|
2336
|
+
}
|
|
2337
|
+
if (verb === 'run-now') {
|
|
2338
|
+
if (found.kind === 'once' && found.item.after && found.item.sourceFromPrevious) {
|
|
2339
|
+
const p = predecessorState(found.item.after, { policy: found.item.after.policy, isLive: liveProbe });
|
|
2340
|
+
if (!p.pipelineId || !previousBranchesOf(p.pipelineId)) return out(409, { error: `Start ‘${p.title || 'the run before it'}’ first, or change its source branch` });
|
|
2341
|
+
}
|
|
2342
|
+
const ticket = found.kind === 'recurring' ? runScheduleNow(found.item.id) : requestRunNow(found.item.id);
|
|
2343
|
+
if (!ticket) return out(409, { error: `this ${found.kind === 'recurring' ? 'schedule' : 'run'} is ${found.item.status} and cannot be started` });
|
|
2344
|
+
emitChanged('schedules-changed', 'run-now');
|
|
2345
|
+
emitChanged('notifications-changed');
|
|
2346
|
+
await schedulerTick();
|
|
2347
|
+
const after = getTicket(ticket.id);
|
|
2348
|
+
// A ticket held by a waiting `--wait` terminal is started by that terminal within seconds.
|
|
2349
|
+
return out(200, { runId: ticket.id, status: after ? after.status : 'scheduled', failReason: after ? after.failReason : null, pipelineId: after ? after.pipelineId : null });
|
|
2350
|
+
}
|
|
2351
|
+
if (['pause', 'resume', 'skip-next'].includes(verb)) {
|
|
2352
|
+
if (found.kind !== 'recurring') return out(404, { error: 'repeating schedule not found' });
|
|
2353
|
+
const s = verb === 'pause' ? pauseSchedule(found.item.id) : verb === 'resume' ? resumeSchedule(found.item.id) : skipNext(found.item.id);
|
|
2354
|
+
if (!s) return out(409, { error: `this schedule is ${found.item.status}` });
|
|
2355
|
+
emitChanged('schedules-changed', verb);
|
|
2356
|
+
emitChanged('notifications-changed');
|
|
2357
|
+
return out(200, { kind: 'recurring', item: s });
|
|
2358
|
+
}
|
|
2359
|
+
return out(400, { error: `unknown schedule action ${verb}` });
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
/** Apply a CONFIRMED Ask Worca schedule card (schedule-spec.mjs shape) through scheduleVerb. */
|
|
2363
|
+
async function applyScheduleCard(card) {
|
|
2364
|
+
const map = { run_now: ['run-now', {}], move: ['patch', card.patch || {}], edit: ['patch', card.patch || {}], cancel: ['delete', {}], delete: ['delete', {}] };
|
|
2365
|
+
const m = map[card.action];
|
|
2366
|
+
if (!m) return { ok: false, error: `unknown schedule action "${card.action}"` };
|
|
2367
|
+
const r = await scheduleVerb(m[0], card.id, m[1]);
|
|
2368
|
+
if (r.status !== 200) return { ok: false, error: (r.body && r.body.error) || `failed (${r.status})` };
|
|
2369
|
+
const b = r.body || {};
|
|
2370
|
+
let detail = '';
|
|
2371
|
+
if (card.action === 'run_now') {
|
|
2372
|
+
detail = b.status === 'failed' ? `could not start: ${b.failReason || 'unknown error'}`
|
|
2373
|
+
: b.status === 'fired' ? `started${b.pipelineId ? ` as run ${b.pipelineId}` : ''}` : 'starting within seconds';
|
|
2374
|
+
if (b.status === 'failed') return { ok: false, error: `could not start: ${b.failReason || 'unknown error'}`, runId: b.runId };
|
|
2375
|
+
} else if (card.action === 'move') detail = card.after && card.after.when ? `now at ${card.after.when}` : card.after && card.after.afterRun ? `now after ‘${card.after.afterRun.title || card.after.afterRun.id}’` : 'moved';
|
|
2376
|
+
else if (card.action === 'edit') {
|
|
2377
|
+
const it = b.item;
|
|
2378
|
+
detail = !it ? 'changed'
|
|
2379
|
+
: it.nextRunAt ? `next run ${formatInstant(Date.parse(it.nextRunAt), it.tz || 'UTC')}`
|
|
2380
|
+
: it.status === 'paused' ? 'still paused — resume it to run again' : `the schedule is ${it.status}`;
|
|
2381
|
+
}
|
|
2382
|
+
else detail = card.action === 'cancel' ? 'canceled' : 'deleted';
|
|
2383
|
+
return { ok: true, detail, ...(b.runId ? { runId: b.runId } : {}), ...(b.pipelineId ? { pipelineId: b.pipelineId } : {}) };
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
// PATCH /api/schedules/:id — a ticket: { scheduledFor?, ifMissed?, graceMin? };
|
|
2387
|
+
// a series: { title?, rule?, overlap?, maxFailures?, ifMissed?, graceMin? }.
|
|
2388
|
+
app.patch('/api/schedules/:id', async (req, res) => {
|
|
2389
|
+
const r = await scheduleVerb('patch', req.params.id, req.body || {});
|
|
2390
|
+
res.status(r.status).json(r.body);
|
|
2391
|
+
});
|
|
2392
|
+
|
|
2393
|
+
// DELETE /api/schedules/:id — cancel a one-shot ticket, or delete a series.
|
|
2394
|
+
app.delete('/api/schedules/:id', async (req, res) => {
|
|
2395
|
+
const r = await scheduleVerb('delete', req.params.id);
|
|
2396
|
+
res.status(r.status).json(r.body);
|
|
2397
|
+
});
|
|
2398
|
+
|
|
2399
|
+
// POST /api/schedules/:id/run-now — start a ticket now, or one extra occurrence of a series.
|
|
2400
|
+
app.post('/api/schedules/:id/run-now', async (req, res) => {
|
|
2401
|
+
const r = await scheduleVerb('run-now', req.params.id);
|
|
2402
|
+
res.status(r.status).json(r.body);
|
|
2403
|
+
});
|
|
2404
|
+
|
|
2405
|
+
for (const verb of ['pause', 'resume', 'skip-next']) {
|
|
2406
|
+
app.post(`/api/schedules/:id/${verb}`, async (req, res) => {
|
|
2407
|
+
const r = await scheduleVerb(verb, req.params.id);
|
|
2408
|
+
res.status(r.status).json(r.body);
|
|
2409
|
+
});
|
|
2410
|
+
}
|
|
2411
|
+
|
|
2412
|
+
// GET /api/notifications?scope=schedule[&unread=1][&problems=1] -> { notifications, unread }
|
|
2413
|
+
app.get('/api/notifications', (req, res) => {
|
|
2414
|
+
try {
|
|
2415
|
+
const scope = typeof req.query.scope === 'string' && req.query.scope ? req.query.scope : 'schedule';
|
|
2416
|
+
const flag = (v) => v === '1' || v === 'true';
|
|
2417
|
+
res.json({
|
|
2418
|
+
notifications: listNotifications({ scope, unread: flag(req.query.unread), problems: flag(req.query.problems) }),
|
|
2419
|
+
unread: unreadCount(scope),
|
|
2420
|
+
});
|
|
2421
|
+
} catch (err) {
|
|
2422
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2423
|
+
}
|
|
2424
|
+
});
|
|
2425
|
+
|
|
2426
|
+
app.post('/api/notifications/read-all', (req, res) => {
|
|
2427
|
+
const scope = req.body && typeof req.body.scope === 'string' && req.body.scope ? req.body.scope : 'schedule';
|
|
2428
|
+
markAllRead(scope);
|
|
2429
|
+
emitChanged('notifications-changed');
|
|
2430
|
+
res.json({ unread: unreadCount(scope) });
|
|
2431
|
+
});
|
|
2432
|
+
|
|
2433
|
+
app.post('/api/notifications/:id/read', (req, res) => {
|
|
2434
|
+
const id = Number(req.params.id);
|
|
2435
|
+
if (!Number.isSafeInteger(id)) return badRequest(res, 'invalid notification id');
|
|
2436
|
+
const read = !(req.body && req.body.read === false);
|
|
2437
|
+
if (!markRead(id, { read })) return res.status(404).json({ error: 'notification not found' });
|
|
2438
|
+
emitChanged('notifications-changed');
|
|
2439
|
+
res.json({ unread: unreadCount('schedule') });
|
|
2440
|
+
});
|
|
2441
|
+
|
|
1625
2442
|
// ---------------------------------------------------------------------------
|
|
1626
2443
|
// Chat connectivity (chat-connectivity-design.md): persistent channel workers
|
|
1627
2444
|
// + inbound command router. Workers are dumb transports; every command
|
|
@@ -1640,6 +2457,11 @@ const chatContext = {
|
|
|
1640
2457
|
|
|
1641
2458
|
const chatActions = {
|
|
1642
2459
|
listRuns: () => summarizeRuns(),
|
|
2460
|
+
// Waiting + missed tickets, soonest first, with the time in this server's zone.
|
|
2461
|
+
listScheduled: () => listTickets().map((t) => ({
|
|
2462
|
+
id: t.id, title: t.title, runAt: t.runAt, status: t.status, projectDir: t.projectDir,
|
|
2463
|
+
when: formatInstant(Date.parse(t.runAt), Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'),
|
|
2464
|
+
})),
|
|
1643
2465
|
runState: (runId) => { try { return runs.get(runId)?.orch?.getState() ?? null; } catch { return null; } },
|
|
1644
2466
|
pendingQuestion: (runId) => runs.get(runId)?.pendingQuestion ?? null,
|
|
1645
2467
|
answer: (runId, id, payload) => answerRun(runId, id, payload),
|
|
@@ -1742,6 +2564,18 @@ function reloadChatWorkers(name) {
|
|
|
1742
2564
|
// from Discord clears the question card in every browser tab exactly like the
|
|
1743
2565
|
// UI button does (resolvePending is part of the action, not the route).
|
|
1744
2566
|
// ---------------------------------------------------------------------------
|
|
2567
|
+
/**
|
|
2568
|
+
* Resolve a pending question. ONE implementation behind the HTTP route and the
|
|
2569
|
+
* chat command router (`chatActions.answer`), so answering from Discord clears the
|
|
2570
|
+
* card in every browser tab exactly like the UI button does.
|
|
2571
|
+
*
|
|
2572
|
+
* Ask forms, gate 3 (ruling X2): `orch.answer` THROWS `Error('invalid answer')`
|
|
2573
|
+
* with `code: 'INVALID_ANSWER'` and `errors: [{ path, code, message }]` when the
|
|
2574
|
+
* values are refused. That throw is RETHROWN unchanged and must never be caught
|
|
2575
|
+
* here — `resolvePending` below must not run, or the question would be cleared in
|
|
2576
|
+
* every tab while the run is still waiting on it. Callers (`POST /api/answer` →
|
|
2577
|
+
* 422, and P4's CLI + chat) branch on `err.code === 'INVALID_ANSWER'`.
|
|
2578
|
+
*/
|
|
1745
2579
|
function answerRun(runId, id, payload) {
|
|
1746
2580
|
const entry = runs.get(runId);
|
|
1747
2581
|
if (!entry) throw new Error('unknown runId');
|
|
@@ -1776,6 +2610,12 @@ app.post('/api/answer', (req, res) => {
|
|
|
1776
2610
|
answerRun(runId, id, payload);
|
|
1777
2611
|
res.json({ ok: true });
|
|
1778
2612
|
} catch (err) {
|
|
2613
|
+
// Gate 3 (ask forms, spec §5): the answer was validated and refused, so the
|
|
2614
|
+
// question stays OPEN and the client gets the field errors to mark up. Every
|
|
2615
|
+
// other failure keeps its 500.
|
|
2616
|
+
if (err && err.code === 'INVALID_ANSWER') {
|
|
2617
|
+
return res.status(422).json({ error: 'invalid answer', errors: Array.isArray(err.errors) ? err.errors : [] });
|
|
2618
|
+
}
|
|
1779
2619
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
1780
2620
|
}
|
|
1781
2621
|
});
|
|
@@ -1828,7 +2668,7 @@ class ResumeError extends Error {
|
|
|
1828
2668
|
}
|
|
1829
2669
|
}
|
|
1830
2670
|
|
|
1831
|
-
async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {}) {
|
|
2671
|
+
async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false, pastTeamCap = false, policyReason = null } = {}) {
|
|
1832
2672
|
if (!pipelineId || typeof pipelineId !== 'string') throw new ResumeError(400, { error: 'pipelineId is required' });
|
|
1833
2673
|
const saved = readPipelineForResume(pipelineId);
|
|
1834
2674
|
if (!saved) throw new ResumeError(404, { error: 'pipeline not found' });
|
|
@@ -1897,6 +2737,19 @@ async function resumeRun(pipelineId, { ignoreCostCap = false, mock = false } = {
|
|
|
1897
2737
|
if (!projectDir) throw new ResumeError(400, { error: 'project for this pipeline is not onboarded on this machine' });
|
|
1898
2738
|
}
|
|
1899
2739
|
|
|
2740
|
+
// Team policy gates (design §7): the total cap once per window per home, the pipeline cap once
|
|
2741
|
+
// per run. Both soft — `pastTeamCap` (with an optional / required reason) records the choice
|
|
2742
|
+
// and lets the resume through; the local gates above stay never-bypassable.
|
|
2743
|
+
const teamTarget = workspace ? { workspaceId: workspace.id } : { projectDir };
|
|
2744
|
+
const totalGate = await checkTeamTotalGate(teamTarget, { pastTeamCap, reason: policyReason });
|
|
2745
|
+
if (totalGate.blocked) {
|
|
2746
|
+
throw new ResumeError(totalGate.code === 'reason_required' ? 400 : 403, { error: totalGate.error, code: totalGate.code, policy: totalGate.policy, needsPolicyAck: totalGate.code === 'team_total' });
|
|
2747
|
+
}
|
|
2748
|
+
const pipeGate = checkTeamPipelineGate(totalGate.caps, { pipelineId, spentSoFar, pastTeamCap, reason: policyReason });
|
|
2749
|
+
if (pipeGate.blocked) {
|
|
2750
|
+
throw new ResumeError(pipeGate.code === 'reason_required' ? 400 : 403, { error: pipeGate.error, code: pipeGate.code, policy: pipeGate.policy, needsPolicyOverride: pipeGate.code === 'team_pipeline' });
|
|
2751
|
+
}
|
|
2752
|
+
|
|
1900
2753
|
// A paused defragment run is not live; another one on the same scope may have started since.
|
|
1901
2754
|
// Resuming the first would sync its stale mount over the second's work (spec §5 concurrency).
|
|
1902
2755
|
const rpScope = saved.resumePoint?.memoryScope || null;
|
|
@@ -1986,6 +2839,8 @@ app.post('/api/resume', async (req, res) => {
|
|
|
1986
2839
|
const out = await resumeRun(req.body?.pipelineId, {
|
|
1987
2840
|
ignoreCostCap: req.body?.ignoreCostCap === true,
|
|
1988
2841
|
mock: !!(req.body && req.body.mock),
|
|
2842
|
+
pastTeamCap: req.body?.pastTeamCap === true,
|
|
2843
|
+
policyReason: typeof req.body?.policyReason === 'string' ? req.body.policyReason : null,
|
|
1989
2844
|
});
|
|
1990
2845
|
res.json(out);
|
|
1991
2846
|
} catch (err) {
|
|
@@ -2013,7 +2868,7 @@ app.get('/api/runs', async (req, res) => {
|
|
|
2013
2868
|
const live = [...runs.values()]
|
|
2014
2869
|
.filter((r) => r.workspaceId === ws.id)
|
|
2015
2870
|
.map((r) => ({ id: r.pipelineId || r.id, runId: r.id, title: r.title, status: r.status, live: true }));
|
|
2016
|
-
return res.json({ pipelines, live, ghAvailable: await hasGh() });
|
|
2871
|
+
return res.json({ pipelines, live, scheduled: listTickets({ workspaceId: ws.id }), ghAvailable: await hasGh() });
|
|
2017
2872
|
} catch (err) {
|
|
2018
2873
|
return res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2019
2874
|
}
|
|
@@ -2037,7 +2892,8 @@ app.get('/api/runs', async (req, res) => {
|
|
|
2037
2892
|
status: r.status,
|
|
2038
2893
|
live: true,
|
|
2039
2894
|
}));
|
|
2040
|
-
|
|
2895
|
+
// Additive: runs that WAIT for their start time are tickets, not pipelines.
|
|
2896
|
+
res.json({ pipelines, live, scheduled: listTickets({ projectDir }), ghAvailable: await hasGh() });
|
|
2041
2897
|
} catch (err) {
|
|
2042
2898
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2043
2899
|
}
|
|
@@ -2088,6 +2944,72 @@ app.get('/api/runs/:id/artifacts', async (req, res) => {
|
|
|
2088
2944
|
}
|
|
2089
2945
|
});
|
|
2090
2946
|
|
|
2947
|
+
// ---------------------------------------------------------------------------
|
|
2948
|
+
// Ask-form preview files (spec §7, D8). Served by (run, askId, index) — there is
|
|
2949
|
+
// NO path input on this surface: `:askId` is a token askIdToken() minted,
|
|
2950
|
+
// `:index` is a small integer, and the only string joined onto a directory is the
|
|
2951
|
+
// basename the snapshot's own manifest.json recorded. Posture copied from the Ask
|
|
2952
|
+
// attachment route: streamed sendFile, dotfiles denied, the SNIFFED type, nosniff,
|
|
2953
|
+
// inline, immutable private cache. Behind the loopback guard like every route.
|
|
2954
|
+
// ---------------------------------------------------------------------------
|
|
2955
|
+
const ASK_FILES_TOKEN_RE = /^[A-Za-z0-9_-]{1,96}$/;
|
|
2956
|
+
|
|
2957
|
+
async function serveAskFile(res, runDir, askId, index) {
|
|
2958
|
+
if (!runDir) return res.status(404).json({ error: 'pipeline not found' });
|
|
2959
|
+
if (typeof askId !== 'string' || !ASK_FILES_TOKEN_RE.test(askId)) {
|
|
2960
|
+
return res.status(400).json({ error: 'invalid ask id' });
|
|
2961
|
+
}
|
|
2962
|
+
if (typeof index !== 'string' || !/^\d{1,2}$/.test(index)) {
|
|
2963
|
+
return res.status(400).json({ error: 'invalid file index' });
|
|
2964
|
+
}
|
|
2965
|
+
const dir = path.join(runDir, ASK_FILES_DIR, askId);
|
|
2966
|
+
const entry = await readAskFileEntry(dir, Number(index));
|
|
2967
|
+
if (!entry) return res.status(404).json({ error: 'ask file not found' });
|
|
2968
|
+
// The text classes are PROVEN UTF-8 by the sniffer (a fatal decoder), so say so:
|
|
2969
|
+
// under nosniff a bare `text/plain` is decoded with the browser's legacy default
|
|
2970
|
+
// charset on a direct open, and non-ASCII prose garbles. Binary types stay bare.
|
|
2971
|
+
const type = ASK_FILE_MIMES[entry.mime].trust === 'text' ? `${entry.mime}; charset=utf-8` : entry.mime;
|
|
2972
|
+
const headers = {
|
|
2973
|
+
'Content-Type': type,
|
|
2974
|
+
'X-Content-Type-Options': 'nosniff',
|
|
2975
|
+
'Content-Disposition': 'inline',
|
|
2976
|
+
'Cache-Control': 'private, max-age=31536000, immutable',
|
|
2977
|
+
};
|
|
2978
|
+
// §7's "inline, inert only" row: an SVG is drawn through <img> in the panel,
|
|
2979
|
+
// and a direct hit on this URL is inert too — no script, no fetch, no anything.
|
|
2980
|
+
if (entry.mime === 'image/svg+xml') headers['Content-Security-Policy'] = "sandbox; default-src 'none'";
|
|
2981
|
+
res.sendFile(entry.stored, { root: dir, dotfiles: 'deny', cacheControl: false, headers }, (err) => {
|
|
2982
|
+
if (!err || res.headersSent) return;
|
|
2983
|
+
if (err.code === 'ENOENT' || err.status === 404) return res.status(404).json({ error: 'ask file not found' });
|
|
2984
|
+
res.status(500).json({ error: err.message || String(err) });
|
|
2985
|
+
});
|
|
2986
|
+
}
|
|
2987
|
+
|
|
2988
|
+
/** The run dir behind a LIVE id: the pipeline id (what /api/runs/:id/artifact
|
|
2989
|
+
* takes) or the WebSocket run UUID (what the browser holds). */
|
|
2990
|
+
async function askFilesRunDir(id) {
|
|
2991
|
+
const live = liveRunEntry(id);
|
|
2992
|
+
const row = findPipelineRowById(live?.pipelineId || id);
|
|
2993
|
+
if (!row) return null;
|
|
2994
|
+
try { return await runDirForRow(row); } catch { return null; }
|
|
2995
|
+
}
|
|
2996
|
+
|
|
2997
|
+
app.get('/api/runs/:id/ask-files/:askId/:index', async (req, res) => {
|
|
2998
|
+
try {
|
|
2999
|
+
await serveAskFile(res, await askFilesRunDir(req.params.id), req.params.askId, req.params.index);
|
|
3000
|
+
} catch (err) {
|
|
3001
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3002
|
+
}
|
|
3003
|
+
});
|
|
3004
|
+
|
|
3005
|
+
/** The run dir behind a STORE KEY + run id — the shape both the History and the
|
|
3006
|
+
* workspace twin resolve through. Null (→ 404) when the row or its dir is gone. */
|
|
3007
|
+
async function askFilesStoreDir(key, runId) {
|
|
3008
|
+
const row = lookupPipelineRow(key, runId);
|
|
3009
|
+
if (!row) return null;
|
|
3010
|
+
try { return await runDirForRow(row); } catch { return null; }
|
|
3011
|
+
}
|
|
3012
|
+
|
|
2091
3013
|
// Shared query-scope resolver for the retained-work routes (recovery-patch GET +
|
|
2092
3014
|
// discard POST). Returns null after writing the error response itself. The older
|
|
2093
3015
|
// DELETE /api/runs/:id route keeps its inline copy DELIBERATELY (it shadows the
|
|
@@ -2199,7 +3121,10 @@ app.get('/api/onboarding', async (_req, res) => {
|
|
|
2199
3121
|
catch (err) { res.status(500).json({ error: err && err.message ? err.message : String(err) }); }
|
|
2200
3122
|
});
|
|
2201
3123
|
app.post('/api/onboarding', async (req, res) => {
|
|
2202
|
-
try {
|
|
3124
|
+
try {
|
|
3125
|
+
await pinUiLevel(); // before welcomeSeen flips and ends "fresh install"
|
|
3126
|
+
await setOnboardingPrefs(req.body || {});
|
|
3127
|
+
}
|
|
2203
3128
|
catch (err) { return badRequest(res, err && err.message ? err.message : String(err)); }
|
|
2204
3129
|
emitChanged('onboarding-changed');
|
|
2205
3130
|
try { res.json(await onboardingStatus()); }
|
|
@@ -2212,6 +3137,7 @@ app.get('/api/counts', (_req, res) => {
|
|
|
2212
3137
|
pipelines: countPipelines(),
|
|
2213
3138
|
projects: countProjects(),
|
|
2214
3139
|
workspaces: countWorkspaces(),
|
|
3140
|
+
schedules: { ...scheduleCounts(), unread: unreadCount('schedule') },
|
|
2215
3141
|
});
|
|
2216
3142
|
} catch (err) {
|
|
2217
3143
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
@@ -2279,16 +3205,18 @@ app.get('/api/team-metrics', async (req, res) => {
|
|
|
2279
3205
|
// `changed` event tells the page to reload. Other clients (Ask tools, CLI, tests) keep the
|
|
2280
3206
|
// inline fetch and get fresh data in one round trip.
|
|
2281
3207
|
const read = await readScope(scope, { refresh: req.query.refresh === '1', defer: req.query.defer === '1' });
|
|
3208
|
+
const humanRateUsd = effectiveHumanRateUsd(read.rateProjectDir);
|
|
2282
3209
|
// Flush trigger: page open (§4.5). reason:'page-open' backs off for 60 s after a failed flush,
|
|
2283
3210
|
// so a failing push cannot loop through flush-failed → WS → page reload → GET → flush.
|
|
2284
3211
|
for (const s of read.sync) if (s.pending > 0) scheduleFlush(s.slug, { reason: 'page-open' });
|
|
2285
3212
|
res.json({
|
|
2286
3213
|
scope: read.scope,
|
|
2287
3214
|
records: read.records,
|
|
3215
|
+
humanRateUsd, // the client re-aggregates with it (money-saved §9.2)
|
|
2288
3216
|
// The page re-aggregates client-side (§4.9 "one fetch serves the session") and asks with
|
|
2289
3217
|
// aggregate=0: at 12k records the unused aggregate added ~3.9 MB to a ~9.9 MB response.
|
|
2290
3218
|
// Other clients (and the API tests) still get it by default.
|
|
2291
|
-
aggregate: req.query.aggregate === '0' ? null : aggregate(read.records, { range, from, to, groupBy }),
|
|
3219
|
+
aggregate: req.query.aggregate === '0' ? null : aggregate(read.records, { range, from, to, groupBy, humanRateUsd }),
|
|
2292
3220
|
stats: read.stats,
|
|
2293
3221
|
sync: read.sync,
|
|
2294
3222
|
refresh: read.refresh,
|
|
@@ -2380,6 +3308,112 @@ app.post('/api/workspaces/:id/metrics-route', async (req, res) => {
|
|
|
2380
3308
|
catch (err) { sendMetricsError(res, err); }
|
|
2381
3309
|
});
|
|
2382
3310
|
|
|
3311
|
+
// ---------------------------------------------------------------------------
|
|
3312
|
+
// Team policy (team-policy design §11): scopes/statuses, the effective document for one
|
|
3313
|
+
// scope, publish, notes for the New pipeline form, enable / follow, workspace routing.
|
|
3314
|
+
// ---------------------------------------------------------------------------
|
|
3315
|
+
const POLICY_ERROR_STATUS = {
|
|
3316
|
+
BAD_REQUEST: 400, NOT_FOUND: 404, NO_ORIGIN: 400, DELEGATE_INVALID: 400, NOT_HOME: 409, NOT_ENABLED: 409,
|
|
3317
|
+
REMOTE_UNREACHABLE: 502, FETCH_FAILED: 502, WORKTREE_FAILED: 500, COMMIT_FAILED: 500,
|
|
3318
|
+
PUSH_REJECTED: 409, PUSH_RETRIES_EXHAUSTED: 409,
|
|
3319
|
+
};
|
|
3320
|
+
function sendPolicyError(res, err) {
|
|
3321
|
+
const code = (err && err.code) || 'INTERNAL';
|
|
3322
|
+
res.status(POLICY_ERROR_STATUS[code] || 500).json({
|
|
3323
|
+
error: err && err.message ? err.message : String(err), code,
|
|
3324
|
+
...(err && err.stderr ? { stderr: err.stderr } : {}),
|
|
3325
|
+
...(err && err.hint ? { hint: err.hint } : {}),
|
|
3326
|
+
...(err && Array.isArray(err.warnings) ? { warnings: err.warnings } : {}),
|
|
3327
|
+
});
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
app.get('/api/policy/scopes', async (req, res) => {
|
|
3331
|
+
try {
|
|
3332
|
+
const scopes = await listPolicyScopes({ discover: req.query.discover === '1' });
|
|
3333
|
+
// The Plugins page strip and the setup checklist: every home's requirements, folded.
|
|
3334
|
+
const docs = [];
|
|
3335
|
+
for (const s of scopes.projects) if (s.home && !docs.some((d) => d.slug === s.home)) {
|
|
3336
|
+
const r = await resolveProjectPolicy(s.path, { discover: false }).catch(() => null);
|
|
3337
|
+
if (r?.ok) docs.push({ slug: r.home, doc: r.doc });
|
|
3338
|
+
}
|
|
3339
|
+
res.json({ ...scopes, requirements: pluginRequirements(docs), blockedPlugins: blockedPluginFindings(docs) });
|
|
3340
|
+
} catch (err) { sendPolicyError(res, err); }
|
|
3341
|
+
});
|
|
3342
|
+
|
|
3343
|
+
app.get('/api/policy', async (req, res) => {
|
|
3344
|
+
const scope = parseScopeParam(req.query.scope);
|
|
3345
|
+
if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
|
|
3346
|
+
try {
|
|
3347
|
+
const { meta, r, workspaceRun, projectDir } = await policyForScope(scope);
|
|
3348
|
+
if (!r.ok) return res.status(404).json({ error: r.detail || `no team policy for this ${scope.kind}`, code: (r.code || r.reason || 'NOT_ENABLED').toString().toUpperCase().replace(/-/g, '_'), scope: meta });
|
|
3349
|
+
res.json(policyPayload(meta, r, { workspaceRun, projectDir }));
|
|
3350
|
+
} catch (err) { sendPolicyError(res, err); }
|
|
3351
|
+
});
|
|
3352
|
+
|
|
3353
|
+
// New pipeline form (board 8): the notes for a selection — caps, off-policy picks, plugin gaps.
|
|
3354
|
+
app.get('/api/policy/notes', async (req, res) => {
|
|
3355
|
+
const scope = parseScopeParam(req.query.scope);
|
|
3356
|
+
if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
|
|
3357
|
+
try {
|
|
3358
|
+
const { meta, r, workspaceRun } = await policyForScope(scope);
|
|
3359
|
+
if (!r.ok) return res.json({ scope: meta, policy: null, notes: [] });
|
|
3360
|
+
const fields = fieldsForRun(r.doc, { workspaceRun });
|
|
3361
|
+
const guardrailsId = typeof req.query.guardrailsId === 'string' && req.query.guardrailsId ? req.query.guardrailsId : 'permissive';
|
|
3362
|
+
const set = await readGuardrailSet(guardrailsId);
|
|
3363
|
+
const models = typeof req.query.models === 'string' && req.query.models ? req.query.models.split(',').filter(Boolean).map((m) => ({ role: null, model: m })) : [];
|
|
3364
|
+
const dev = deviationsFor(fields, { guardrailsId, guardrailSet: set, stepModels: models, installed: installedPluginsMap(), worcaVersion: POLICY_WORCA_VERSION, metricsRecord: null });
|
|
3365
|
+
res.json({ scope: meta, policy: { home: r.home, sha: r.sha, delegated: r.delegated, from: r.from, caps: capSummary(r.doc, { workspaceRun }) }, notes: dev, guardrailsDefault: fields['guardrails.default']?.value ?? null });
|
|
3366
|
+
} catch (err) { sendPolicyError(res, err); }
|
|
3367
|
+
});
|
|
3368
|
+
|
|
3369
|
+
// Publish (board 5): one commit to the home's worca-policy branch; a rejection comes back verbatim.
|
|
3370
|
+
app.put('/api/policy', async (req, res) => {
|
|
3371
|
+
const scope = parseScopeParam(req.body?.scope);
|
|
3372
|
+
if (!scope) return badRequest(res, 'scope must be project:<projectKey> or workspace:<workspaceId>');
|
|
3373
|
+
if (!req.body || typeof req.body.doc !== 'object' || req.body.doc === null) return badRequest(res, 'doc must be the policy document');
|
|
3374
|
+
try {
|
|
3375
|
+
const { r } = await policyForScope(scope);
|
|
3376
|
+
if (!r.ok) return res.status(404).json({ error: r.detail || 'no team policy for this scope', code: 'NOT_ENABLED' });
|
|
3377
|
+
if (!r.homeDir || !fs.existsSync(r.homeDir)) return res.status(409).json({ error: `the policy home ${r.home} is not checked out on this machine`, code: 'NOT_HOME' });
|
|
3378
|
+
const out = await publishPolicy(r.homeDir, req.body.doc, { message: typeof req.body.message === 'string' ? req.body.message : null });
|
|
3379
|
+
res.json({ ok: true, slug: out.slug, sha: out.sha, unchanged: !!out.unchanged, doc: out.doc });
|
|
3380
|
+
} catch (err) { sendPolicyError(res, err); }
|
|
3381
|
+
});
|
|
3382
|
+
|
|
3383
|
+
// Validate without publishing (the editor's live check): the normaliser's warnings, if any.
|
|
3384
|
+
app.post('/api/policy/validate', (req, res) => {
|
|
3385
|
+
const { doc, warnings, unknownSchema } = normalizePolicyDoc(req.body?.doc);
|
|
3386
|
+
res.json({ ok: !!doc && !unknownSchema && warnings.length === 0, warnings, doc });
|
|
3387
|
+
});
|
|
3388
|
+
|
|
3389
|
+
app.post('/api/policy/discover', async (_req, res) => {
|
|
3390
|
+
try { await discoverAllPolicies({ force: true }); res.json(await listPolicyScopes()); }
|
|
3391
|
+
catch (err) { sendPolicyError(res, err); }
|
|
3392
|
+
});
|
|
3393
|
+
|
|
3394
|
+
app.get('/api/projects/:key/policy', async (req, res) => {
|
|
3395
|
+
const p = await tmProject(req, res); if (!p) return;
|
|
3396
|
+
try { res.json({ status: await projectPolicyStatus(p, { discover: req.query.discover === '1' }) }); }
|
|
3397
|
+
catch (err) { sendPolicyError(res, err); }
|
|
3398
|
+
});
|
|
3399
|
+
|
|
3400
|
+
app.post('/api/projects/:key/policy/enable', async (req, res) => {
|
|
3401
|
+
const p = await tmProject(req, res); if (!p) return;
|
|
3402
|
+
const body = req.body || {};
|
|
3403
|
+
const mode = body.mode === 'follow' ? 'follow' : body.mode === 'here' || body.mode == null ? 'here' : null;
|
|
3404
|
+
if (!mode) return badRequest(res, 'mode must be "here" or "follow"');
|
|
3405
|
+
try {
|
|
3406
|
+
const result = await enableTeamPolicy(p.path, { mode, delegateTo: body.delegateTo || null, change: body.change === true, title: typeof body.title === 'string' ? body.title : '' });
|
|
3407
|
+
res.json({ ...result, status: await projectPolicyStatus(p) });
|
|
3408
|
+
} catch (err) { sendPolicyError(res, err); }
|
|
3409
|
+
});
|
|
3410
|
+
|
|
3411
|
+
app.post('/api/workspaces/:id/policy-route', async (req, res) => {
|
|
3412
|
+
if (!WORKSPACE_KEY_RE.test(req.params.id)) return res.status(404).json({ error: 'workspace not found', code: 'NOT_FOUND' });
|
|
3413
|
+
try { res.json(await routeWorkspaceMembersPolicy(req.params.id)); }
|
|
3414
|
+
catch (err) { sendPolicyError(res, err); }
|
|
3415
|
+
});
|
|
3416
|
+
|
|
2383
3417
|
// ---------------------------------------------------------------------------
|
|
2384
3418
|
// POST /api/history/pr -> enrich the skeleton with live PR state, pushed back
|
|
2385
3419
|
// over the WS as batched `history-pr` events (reuses broadcast(), the same
|
|
@@ -2666,6 +3700,20 @@ app.get('/api/history/:key/:id/artifact', async (req, res) => {
|
|
|
2666
3700
|
}
|
|
2667
3701
|
});
|
|
2668
3702
|
|
|
3703
|
+
// GET /api/history/:key/:id/ask-files/:askId/:index -> the same bytes, resolved
|
|
3704
|
+
// through the store key. Same key regex as /diff and /artifact.
|
|
3705
|
+
app.get('/api/history/:key/:id/ask-files/:askId/:index', async (req, res) => {
|
|
3706
|
+
if (!/^[a-z0-9][a-z0-9-]*-[0-9a-f]{8}$/.test(req.params.key)) {
|
|
3707
|
+
return res.status(404).json({ error: 'pipeline not found' });
|
|
3708
|
+
}
|
|
3709
|
+
try {
|
|
3710
|
+
await serveAskFile(res, await askFilesStoreDir(req.params.key, req.params.id),
|
|
3711
|
+
req.params.askId, req.params.index);
|
|
3712
|
+
} catch (err) {
|
|
3713
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
3714
|
+
}
|
|
3715
|
+
});
|
|
3716
|
+
|
|
2669
3717
|
// ---------------------------------------------------------------------------
|
|
2670
3718
|
// DELETE /api/runs/:id?projectKey=... (or ?projectDir=...)
|
|
2671
3719
|
// ARCHIVE a FINISHED pipeline: reclaims everything on disk — its store folder,
|
|
@@ -2797,26 +3845,43 @@ async function resolvePrPipeline(src, res) {
|
|
|
2797
3845
|
// the ship-it dialog plus the defaults POST /api/pr applies when the body names
|
|
2798
3846
|
// none. Same pipeline resolution as POST /api/pr (the repo dir comes from the
|
|
2799
3847
|
// pipeline's store_meta, never from the query). gh is not required here.
|
|
3848
|
+
// The base-branch choices ride along: `chain` is the run chain's base branches,
|
|
3849
|
+
// root first (a run outside a chain: just its source), `defaultBase` its root, and
|
|
3850
|
+
// `branches` each remote's branches from the LOCAL remote-tracking refs (no fetch),
|
|
3851
|
+
// without HEAD and the run's own feature branch. A git remote failure still carries
|
|
3852
|
+
// the chain so the dialog can offer it.
|
|
2800
3853
|
// -> { ok, remotes:[{name,fetchUrl,pushUrl,host,owner,repo,slug}],
|
|
2801
|
-
// defaults:{pushRemote,baseRemote}, remembered:{pushRemote,baseRemote}|null
|
|
3854
|
+
// defaults:{pushRemote,baseRemote}, remembered:{pushRemote,baseRemote}|null,
|
|
3855
|
+
// chain:[branch], defaultBase:branch|null, branches:{[remote]:[branch]} }
|
|
2802
3856
|
// ---------------------------------------------------------------------------
|
|
2803
3857
|
app.get('/api/pr/remotes', async (req, res) => {
|
|
2804
3858
|
const resolved = await resolvePrPipeline(req.query || {}, res);
|
|
2805
3859
|
if (!resolved) return;
|
|
2806
3860
|
const repoDir = resolved.state.projectDir; // null when store_meta is missing
|
|
2807
3861
|
if (!repoDir) return badRequest(res, 'pipeline has no project directory');
|
|
3862
|
+
const feature = resolved.state.branch && resolved.state.branch.feature;
|
|
3863
|
+
const source = resolved.state.branch && resolved.state.branch.source;
|
|
3864
|
+
const walked = chainBaseBranchesOf(resolved.state.id || resolved.id);
|
|
3865
|
+
const chain = (walked.length ? walked : (source ? [source] : [])).filter((b) => b !== feature);
|
|
3866
|
+
const defaultBase = chain[0] || null;
|
|
2808
3867
|
const rl = await listRemotes(repoDir);
|
|
2809
|
-
if (!rl.ok) return res.status(500).json({ error: `git remote failed: ${rl.error}
|
|
3868
|
+
if (!rl.ok) return res.status(500).json({ error: `git remote failed: ${rl.error}`, chain, defaultBase });
|
|
2810
3869
|
const remembered = readPrRemotePrefs(repoDir);
|
|
2811
|
-
|
|
3870
|
+
const rb = await listRemoteBranches(repoDir, rl.remotes.map((r) => r.name));
|
|
3871
|
+
const branches = {};
|
|
3872
|
+
for (const [name, list] of Object.entries(rb.byRemote)) branches[name] = list.filter((b) => b !== feature);
|
|
3873
|
+
res.json({ ok: true, remotes: rl.remotes, defaults: defaultPrRemotes(rl.remotes, remembered), remembered,
|
|
3874
|
+
chain, defaultBase, branches });
|
|
2812
3875
|
});
|
|
2813
3876
|
|
|
2814
3877
|
// ---------------------------------------------------------------------------
|
|
2815
3878
|
// POST /api/pr -> push the pipeline's feature branch (if needed) and open a PR
|
|
2816
|
-
// against its source branch via the GitHub CLI.
|
|
2817
|
-
// here (never during list rendering).
|
|
2818
|
-
// body: { id, projectDir?, projectKey?, pushRemote?, baseRemote? } —
|
|
2819
|
-
// are validated against the repo's real remote list (never trusted
|
|
3879
|
+
// against its source branch (or the dialog's `baseBranch`) via the GitHub CLI.
|
|
3880
|
+
// Mergeability is read back only here (never during list rendering).
|
|
3881
|
+
// body: { id, projectDir?, projectKey?, pushRemote?, baseRemote?, baseBranch? } —
|
|
3882
|
+
// remote names are validated against the repo's real remote list (never trusted
|
|
3883
|
+
// from the body); baseBranch must be a well-formed ref other than the feature
|
|
3884
|
+
// branch (whether the base repo has it is gh's call, its error surfaces as usual).
|
|
2820
3885
|
// ---------------------------------------------------------------------------
|
|
2821
3886
|
app.post('/api/pr', async (req, res) => {
|
|
2822
3887
|
const body = req.body || {};
|
|
@@ -2834,6 +3899,15 @@ app.post('/api/pr', async (req, res) => {
|
|
|
2834
3899
|
if (!repoDir || !feature || !source) {
|
|
2835
3900
|
return badRequest(res, 'pipeline has no branch info to open a PR');
|
|
2836
3901
|
}
|
|
3902
|
+
// The base branch: the dialog's pick (a run chain defaults to its root there),
|
|
3903
|
+
// else the run's own source. Per run — never remembered with the remotes.
|
|
3904
|
+
let base = source;
|
|
3905
|
+
if (body.baseBranch !== undefined && body.baseBranch !== null) {
|
|
3906
|
+
const b = typeof body.baseBranch === 'string' ? body.baseBranch.trim() : '';
|
|
3907
|
+
if (!isSyntacticRef(b)) return badRequest(res, `invalid base branch: ${String(body.baseBranch).slice(0, 80)}`);
|
|
3908
|
+
if (b === feature) return badRequest(res, 'the base branch cannot be the feature branch');
|
|
3909
|
+
base = b;
|
|
3910
|
+
}
|
|
2837
3911
|
|
|
2838
3912
|
// Remote selection. A named remote must exist; unnamed ones take the dialog's
|
|
2839
3913
|
// defaults. With no usable remote list (no remotes, unparseable URLs) the legacy
|
|
@@ -2876,7 +3950,7 @@ app.post('/api/pr', async (req, res) => {
|
|
|
2876
3950
|
if (!pushed.ok) return res.status(500).json({ error: `git push failed: ${pushed.stderr}` });
|
|
2877
3951
|
|
|
2878
3952
|
const pr = await createPr({
|
|
2879
|
-
projectDir: repoDir, base
|
|
3953
|
+
projectDir: repoDir, base, head: feature, title: state.title || feature, repo, headOwner,
|
|
2880
3954
|
});
|
|
2881
3955
|
if (!pr.ok) return res.status(500).json({ error: `gh pr create failed: ${pr.error}` });
|
|
2882
3956
|
|
|
@@ -2968,7 +4042,23 @@ app.get('/api/branches', async (req, res) => {
|
|
|
2968
4042
|
listLocalBranches(projectDir),
|
|
2969
4043
|
currentBranch(projectDir),
|
|
2970
4044
|
]);
|
|
2971
|
-
|
|
4045
|
+
// Run branches: this project's pipelines whose feature branch still exists (spec D7).
|
|
4046
|
+
// Throw-safe on purpose: test/branches-api.test.mjs calls this route for a temp directory
|
|
4047
|
+
// that is not a registered project (and has no temp home) — the DB read must never turn the
|
|
4048
|
+
// plain branch list into a 500.
|
|
4049
|
+
const have = new Set(branches);
|
|
4050
|
+
const runsOut = [];
|
|
4051
|
+
try {
|
|
4052
|
+
const rows = getDb().prepare(`SELECT id, title, status, updated_at, branch FROM pipelines
|
|
4053
|
+
WHERE project_key = ? AND target = 'project' AND archived_at IS NULL ORDER BY started_at DESC LIMIT 50`).all(projectKey(projectDir));
|
|
4054
|
+
for (const r of rows) {
|
|
4055
|
+
let b = null; try { b = JSON.parse(r.branch || 'null'); } catch { b = null; }
|
|
4056
|
+
if (b && typeof b.feature === 'string' && have.has(b.feature)) runsOut.push({ branch: b.feature, pipelineId: r.id, title: r.title || null, status: r.status, endedAt: r.updated_at || null });
|
|
4057
|
+
}
|
|
4058
|
+
} catch (err) {
|
|
4059
|
+
console.error(`[worca-ui] run branches lookup failed: ${err && err.message ? err.message : err}`);
|
|
4060
|
+
}
|
|
4061
|
+
res.json({ branches, current, runs: runsOut });
|
|
2972
4062
|
} catch (err) {
|
|
2973
4063
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
2974
4064
|
}
|
|
@@ -2985,6 +4075,7 @@ app.get('/api/projects', async (_req, res) => {
|
|
|
2985
4075
|
app.post('/api/projects', async (req, res) => {
|
|
2986
4076
|
const body = req.body || {};
|
|
2987
4077
|
try {
|
|
4078
|
+
await pinUiLevel(); // before the first project ends "fresh install"
|
|
2988
4079
|
const projects = await addProject({ name: body.name, path: body.path });
|
|
2989
4080
|
emitChanged('projects-changed', 'created');
|
|
2990
4081
|
discoverProject(normalizeProjectPath(body.path), { force: true })
|
|
@@ -3003,7 +4094,13 @@ app.delete('/api/projects', async (req, res) => {
|
|
|
3003
4094
|
const name = typeof req.query.name === 'string' ? req.query.name : '';
|
|
3004
4095
|
if (!name.trim()) return badRequest(res, 'name is required');
|
|
3005
4096
|
try {
|
|
4097
|
+
// Scheduled runs of a removed project can never start: cancel them with it.
|
|
4098
|
+
const before = (await listProjects()).find((p) => p && p.name === name.trim());
|
|
3006
4099
|
const projects = await removeProject(name);
|
|
4100
|
+
if (before && before.path) {
|
|
4101
|
+
try { if (cancelForTarget({ projectDir: before.path })) emitChanged('schedules-changed', 'target-removed'); }
|
|
4102
|
+
catch (err) { console.error(`[worca-ui] schedule cleanup failed: ${err && err.message ? err.message : err}`); }
|
|
4103
|
+
}
|
|
3007
4104
|
emitChanged('projects-changed', 'deleted');
|
|
3008
4105
|
res.json({ projects });
|
|
3009
4106
|
} catch (err) {
|
|
@@ -3072,6 +4169,20 @@ async function defragRequest(req, res, { memoryScope, projectKey: key }) {
|
|
|
3072
4169
|
return startRunHandler(req, res);
|
|
3073
4170
|
}
|
|
3074
4171
|
|
|
4172
|
+
/** Settings › Memory as the health card names it: `{ model, effort, label, stale }`, or null when
|
|
4173
|
+
* unset. Checked the way a run checks it (memory-defrag-model.mjs), against this scope's catalog —
|
|
4174
|
+
* the project's own for a project scope, the project-less one for global. `stale`: the model left
|
|
4175
|
+
* the catalog, so a run degrades to the workflow default. */
|
|
4176
|
+
async function defragModelState(catalogDir) {
|
|
4177
|
+
const stored = memoryDefragModel();
|
|
4178
|
+
if (!stored.model) return null;
|
|
4179
|
+
const models = await listModels(catalogDir);
|
|
4180
|
+
const r = resolveDefragModel({ stored, models });
|
|
4181
|
+
if (!r.model) return { model: stored.model, effort: stored.effort, label: stored.model, stale: true };
|
|
4182
|
+
const hit = models.find((m) => m.id === r.model);
|
|
4183
|
+
return { model: r.model, effort: r.effort, label: (hit && hit.label) || r.model, stale: false };
|
|
4184
|
+
}
|
|
4185
|
+
|
|
3075
4186
|
function registerMemoryRoutes(prefix, { family }) {
|
|
3076
4187
|
const scoped = (handler) => async (req, res) => {
|
|
3077
4188
|
try {
|
|
@@ -3097,7 +4208,10 @@ function registerMemoryRoutes(prefix, { family }) {
|
|
|
3097
4208
|
|
|
3098
4209
|
app.get(prefix, scoped(async (_req, res, { scope, key, project }) => {
|
|
3099
4210
|
const report = await memoryScopeReport(memoryRoot(), scope, memoryCaps(), { onError });
|
|
3100
|
-
res.json({
|
|
4211
|
+
res.json({
|
|
4212
|
+
scope: key, project, files: report.entries, state: report.state, health: report.health, defragRunId: liveDefragRun(key)?.id || null,
|
|
4213
|
+
defragModel: await defragModelState(project ? project.path : ''),
|
|
4214
|
+
});
|
|
3101
4215
|
}));
|
|
3102
4216
|
app.get(`${prefix}/files/:name`, scoped(async (req, res, { scope }) => {
|
|
3103
4217
|
const name = named(req, res); if (name === null) return;
|
|
@@ -3226,7 +4340,14 @@ app.post('/api/workspaces', async (req, res) => {
|
|
|
3226
4340
|
// records, if there is exactly one; every other case is "Choose…" on the workspace card.
|
|
3227
4341
|
const explicit = typeof body.metricsProject === 'string' && body.metricsProject ? body.metricsProject : null;
|
|
3228
4342
|
const metricsProject = explicit ?? await autoMetricsHome(projectPaths);
|
|
3229
|
-
|
|
4343
|
+
// Team policy home (design §9): defaults to the metrics home when that member's policy
|
|
4344
|
+
// resolves, else the one member (or shared home) whose policy does; else unset.
|
|
4345
|
+
let policyProject = typeof body.policyProject === 'string' && body.policyProject ? body.policyProject : null;
|
|
4346
|
+
if (!policyProject) {
|
|
4347
|
+
const viaMetrics = metricsProject ? await resolveProjectPolicy(metricsProject, { discover: false }).catch(() => null) : null;
|
|
4348
|
+
policyProject = viaMetrics?.ok ? metricsProject : await autoPolicyHome({ projectPaths }).catch(() => null);
|
|
4349
|
+
}
|
|
4350
|
+
const workspace = await createWorkspace({ name: body.name, projectPaths, description: body.description, metricsProject, policyProject });
|
|
3230
4351
|
emitChanged('workspaces-changed', 'created');
|
|
3231
4352
|
res.status(201).json({ workspace, metricsHomeAuto: !explicit && !!metricsProject });
|
|
3232
4353
|
} catch (err) {
|
|
@@ -3253,9 +4374,16 @@ app.patch('/api/workspaces/:id', async (req, res) => {
|
|
|
3253
4374
|
}
|
|
3254
4375
|
patch.metricsProject = body.metricsProject;
|
|
3255
4376
|
}
|
|
4377
|
+
if ('policyProject' in body) {
|
|
4378
|
+
if (body.policyProject !== null && typeof body.policyProject !== 'string') {
|
|
4379
|
+
return badRequest(res, 'policyProject must be a member project path or null');
|
|
4380
|
+
}
|
|
4381
|
+
patch.policyProject = body.policyProject;
|
|
4382
|
+
}
|
|
3256
4383
|
try {
|
|
3257
4384
|
const workspace = await updateWorkspace(id, patch);
|
|
3258
4385
|
if ('metricsProject' in patch) emitChanged('workspaces-changed', 'metrics-home');
|
|
4386
|
+
if ('policyProject' in patch) { emitChanged('workspaces-changed', 'policy-home'); emitChanged('team-policy-changed', 'policy-home'); }
|
|
3259
4387
|
res.json({ workspace });
|
|
3260
4388
|
} catch (err) {
|
|
3261
4389
|
const status = workspaceErrorStatus(err && err.code);
|
|
@@ -3274,6 +4402,8 @@ app.delete('/api/workspaces/:id', async (req, res) => {
|
|
|
3274
4402
|
if (live) return res.status(409).json({ error: 'cannot delete a workspace with a live run or scan' });
|
|
3275
4403
|
try {
|
|
3276
4404
|
const report = await deleteWorkspace(id);
|
|
4405
|
+
try { if (cancelForTarget({ workspaceId: id })) emitChanged('schedules-changed', 'target-removed'); }
|
|
4406
|
+
catch (err) { console.error(`[worca-ui] schedule cleanup failed: ${err && err.message ? err.message : err}`); }
|
|
3277
4407
|
emitChanged('workspaces-changed', 'deleted');
|
|
3278
4408
|
res.json({ ok: true, warnings: (report && report.warnings) || [] });
|
|
3279
4409
|
} catch (err) {
|
|
@@ -3454,6 +4584,19 @@ app.get('/api/workspaces/:id/runs/:runId/artifact', async (req, res) => {
|
|
|
3454
4584
|
}
|
|
3455
4585
|
});
|
|
3456
4586
|
|
|
4587
|
+
// The ask-form preview twin for a workspace run (see the project route above).
|
|
4588
|
+
// WORKSPACE_KEY_RE is the only validation the id needs; the key is composed here
|
|
4589
|
+
// and never taken from the client, so there is no path-traversal surface.
|
|
4590
|
+
app.get('/api/workspaces/:wid/runs/:id/ask-files/:askId/:index', async (req, res) => {
|
|
4591
|
+
if (!WORKSPACE_KEY_RE.test(req.params.wid)) return res.status(404).json({ error: 'pipeline not found' });
|
|
4592
|
+
try {
|
|
4593
|
+
await serveAskFile(res, await askFilesStoreDir(`workspaces/${req.params.wid}`, req.params.id),
|
|
4594
|
+
req.params.askId, req.params.index);
|
|
4595
|
+
} catch (err) {
|
|
4596
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4597
|
+
}
|
|
4598
|
+
});
|
|
4599
|
+
|
|
3457
4600
|
// ---------------------------------------------------------------------------
|
|
3458
4601
|
// GET /api/settings -> { root, projectsRoot, projectsRootDefault, default }
|
|
3459
4602
|
// root : the configured Worca CC data-root base, '' when unset
|
|
@@ -3489,6 +4632,7 @@ const settingsState = () => ({
|
|
|
3489
4632
|
pipelineCostLimitUsd: pipelineCostLimitUsd(),
|
|
3490
4633
|
totalCostLimitUsd: totalCostLimitUsd(),
|
|
3491
4634
|
costLimitResetPeriod: costLimitResetPeriod(),
|
|
4635
|
+
humanRateUsdPerHour: humanRateUsdPerHour(),
|
|
3492
4636
|
askMaxTurns: askMaxTurns(),
|
|
3493
4637
|
askMaxBudgetUsd: askMaxBudgetUsd(),
|
|
3494
4638
|
debugSpawnEnabled: storedDebugSpawnEnabled(), // what is STORED (the checkbox)
|
|
@@ -3497,6 +4641,10 @@ const settingsState = () => ({
|
|
|
3497
4641
|
titleModelEffective: describeTitleModel(), // env override / stale id, for the hint line (#422)
|
|
3498
4642
|
hideBuiltinModels: hideBuiltinModels(),
|
|
3499
4643
|
theme: storedTheme(), // system | light | dark (dark-mode design §6)
|
|
4644
|
+
schedule: scheduleDefaults(), // defaults a NEW schedule inherits
|
|
4645
|
+
uiLevel: effectiveUiLevel(), // simple | advanced | expert (docs/ui-levels.md)
|
|
4646
|
+
memoryDefrag: memoryDefragModel(), // Settings › Memory: the STORED { model, effort } (null = the workflow default)
|
|
4647
|
+
memoryDefragDefault: defragDefaultModel(), // what "(default)" means there: the built-in's own model
|
|
3500
4648
|
});
|
|
3501
4649
|
|
|
3502
4650
|
/** Settings ▸ Auto workflow model: the stored id + what the classifier will actually use
|
|
@@ -3559,20 +4707,32 @@ app.get('/api/settings', async (_req, res) => {
|
|
|
3559
4707
|
});
|
|
3560
4708
|
|
|
3561
4709
|
app.get('/api/budget', (_req, res) => {
|
|
3562
|
-
|
|
4710
|
+
const budget = budgetStatus();
|
|
4711
|
+
// The sidebar's "Saved this month" figure rides on this snapshot (money-saved design §10).
|
|
4712
|
+
// Additive and best-effort: a failed savings read must never cost the gate figures the
|
|
4713
|
+
// New-view Start button and every cost banner key on, so it degrades to nulls instead.
|
|
4714
|
+
let savings = { windowHumanHours: null, windowSavedUsd: null };
|
|
4715
|
+
try { savings = budgetWindowSavings(budget); } catch { /* keep the nulls */ }
|
|
4716
|
+
res.json({ ...budget, ...savings });
|
|
3563
4717
|
});
|
|
3564
4718
|
|
|
3565
4719
|
app.post('/api/settings', async (req, res) => {
|
|
3566
4720
|
const body = req.body || {};
|
|
3567
4721
|
const has = (k) => Object.prototype.hasOwnProperty.call(body, k);
|
|
3568
4722
|
const hasBudgetKey = has('pipelineCostLimitUsd') || has('totalCostLimitUsd') || has('costLimitResetPeriod');
|
|
4723
|
+
const hasHumanRateKey = has('humanRateUsdPerHour');
|
|
3569
4724
|
const hasAskKey = has('askMaxTurns') || has('askMaxBudgetUsd');
|
|
3570
4725
|
const hasDebugSpawnKey = has('debugSpawnEnabled');
|
|
3571
4726
|
const hasTitleModelKey = has('titleModel');
|
|
3572
4727
|
const hasHideBuiltinKey = has('hideBuiltinModels');
|
|
3573
4728
|
const hasThemeKey = has('theme');
|
|
4729
|
+
const hasUiLevelKey = has('uiLevel');
|
|
3574
4730
|
const hasAutoKey = has('autoWorkflowModel');
|
|
3575
4731
|
const autoModels = hasAutoKey ? await listModels('') : null;
|
|
4732
|
+
// Settings › Memory: the defragment { model, effort } pair, checked against the same
|
|
4733
|
+
// project-less catalog the Settings pickers offer (a run re-checks it against its own).
|
|
4734
|
+
const hasMemoryDefragKey = has('memoryDefrag');
|
|
4735
|
+
const defragModels = hasMemoryDefragKey ? (autoModels || await listModels('')) : null;
|
|
3576
4736
|
// #422: the title model is a SELECT over the catalog, so an id that is not a
|
|
3577
4737
|
// catalog member is a client bug (or a stale option) — refuse it here rather
|
|
3578
4738
|
// than store an id resolveModelEnv could never route.
|
|
@@ -3596,6 +4756,7 @@ app.post('/api/settings', async (req, res) => {
|
|
|
3596
4756
|
if (has('askMaxBudgetUsd')) ask.askMaxBudgetUsd = body.askMaxBudgetUsd === undefined ? '' : body.askMaxBudgetUsd;
|
|
3597
4757
|
try {
|
|
3598
4758
|
assertCostLimitInputs(budget);
|
|
4759
|
+
if (hasHumanRateKey) assertHumanRateInput(body.humanRateUsdPerHour ?? '');
|
|
3599
4760
|
assertAskLimitInputs(ask);
|
|
3600
4761
|
if (hasDebugSpawnKey) assertDebugSpawnInput(body.debugSpawnEnabled);
|
|
3601
4762
|
if (hasTitleModelKey) {
|
|
@@ -3606,7 +4767,9 @@ app.post('/api/settings', async (req, res) => {
|
|
|
3606
4767
|
}
|
|
3607
4768
|
if (hasHideBuiltinKey) assertHideBuiltinModelsInput(body.hideBuiltinModels);
|
|
3608
4769
|
if (hasThemeKey) assertThemeInput(body.theme);
|
|
4770
|
+
if (hasUiLevelKey) assertUiLevelInput(body.uiLevel);
|
|
3609
4771
|
if (hasAutoKey) assertAutoWorkflowModelInput(body.autoWorkflowModel ?? '', autoModels);
|
|
4772
|
+
if (hasMemoryDefragKey) assertMemoryDefragModelInput(body.memoryDefrag, defragModels);
|
|
3610
4773
|
// Root first: it is the one key whose setter can still fail AFTER the asserts
|
|
3611
4774
|
// above (an unusable path), so every other key's write must come after it or
|
|
3612
4775
|
// a mixed POST would answer 400 with those keys already applied on disk.
|
|
@@ -3622,17 +4785,21 @@ app.post('/api/settings', async (req, res) => {
|
|
|
3622
4785
|
if (has('pipelineCostLimitUsd')) await setPipelineCostLimitUsd(budget.pipelineCostLimitUsd);
|
|
3623
4786
|
if (has('totalCostLimitUsd')) await setTotalCostLimitUsd(budget.totalCostLimitUsd);
|
|
3624
4787
|
if (has('costLimitResetPeriod')) await setCostLimitResetPeriod(budget.costLimitResetPeriod);
|
|
4788
|
+
if (hasHumanRateKey) await setHumanRateUsdPerHour(body.humanRateUsdPerHour ?? '');
|
|
3625
4789
|
if (has('askMaxTurns')) await setAskMaxTurns(ask.askMaxTurns);
|
|
3626
4790
|
if (has('askMaxBudgetUsd')) await setAskMaxBudgetUsd(ask.askMaxBudgetUsd);
|
|
3627
4791
|
if (hasDebugSpawnKey) await setDebugSpawnEnabled(body.debugSpawnEnabled);
|
|
3628
4792
|
if (hasTitleModelKey) await setTitleModel(titleModelInput);
|
|
3629
4793
|
if (hasHideBuiltinKey) await setHideBuiltinModels(body.hideBuiltinModels);
|
|
3630
4794
|
if (hasThemeKey) await setTheme(body.theme);
|
|
4795
|
+
if (hasUiLevelKey) await setUiLevel(body.uiLevel);
|
|
3631
4796
|
if (hasAutoKey) await setAutoWorkflowModel(body.autoWorkflowModel ?? '', { models: autoModels });
|
|
4797
|
+
if (hasMemoryDefragKey) await setMemoryDefragModel(body.memoryDefrag, { models: defragModels });
|
|
4798
|
+
if (has('schedule')) await setScheduleDefaults(body.schedule && typeof body.schedule === 'object' ? body.schedule : {});
|
|
3632
4799
|
if (hasBudgetKey) emitChanged('budget-changed');
|
|
3633
4800
|
// Other open tabs repaint their Settings cards (a stale tab could otherwise
|
|
3634
4801
|
// "save" its old checkbox state over this one with no feedback to either).
|
|
3635
|
-
if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasAutoKey) emitChanged('settings-changed');
|
|
4802
|
+
if (hasAskKey || hasDebugSpawnKey || hasTitleModelKey || hasHideBuiltinKey || hasThemeKey || hasUiLevelKey || hasAutoKey || hasHumanRateKey || hasMemoryDefragKey || has('schedule')) emitChanged('settings-changed');
|
|
3636
4803
|
res.json({ ...settingsState(), ...(await autoModelState()), chat: chatPrefs() });
|
|
3637
4804
|
} catch (err) {
|
|
3638
4805
|
// The setters throw only on an unusable path -> client error (400).
|
|
@@ -3820,17 +4987,42 @@ app.delete('/api/config/models', async (req, res) => {
|
|
|
3820
4987
|
// ---------------------------------------------------------------------------
|
|
3821
4988
|
|
|
3822
4989
|
const maskEnvValue = (v) => (modelEnvRef(v) ? v : maskModelEnvValue(v));
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
4990
|
+
// A bridged entry's `upstream.apiKey` is masked like an env secret; the rest of
|
|
4991
|
+
// the block is routing config and passes through. `bridged`/`needsSignIn` are
|
|
4992
|
+
// the picker/card facts (model-bridge-design.md §8.5), one readiness check
|
|
4993
|
+
// per provider per response.
|
|
4994
|
+
const bridgeFacts = (m, readiness) => {
|
|
4995
|
+
if (!m.upstream) return {};
|
|
4996
|
+
const p = m.upstream.provider;
|
|
4997
|
+
if (!readiness.has(p)) readiness.set(p, providerReadiness(m.upstream));
|
|
4998
|
+
const r = readiness.get(p);
|
|
4999
|
+
return { bridged: p, needsSignIn: !r.ok, ...(r.ok ? {} : { signInReason: r.reason, signInMessage: r.message }) };
|
|
5000
|
+
};
|
|
5001
|
+
const maskedGlobalModel = (m, readiness = new Map()) => ({
|
|
5002
|
+
...m,
|
|
5003
|
+
...(m.env ? { env: Object.fromEntries(Object.entries(m.env).map(([k, v]) => [k, maskEnvValue(v)])) } : {}),
|
|
5004
|
+
...(m.upstream ? { upstream: { ...m.upstream, ...(m.upstream.apiKey ? { apiKey: maskEnvValue(m.upstream.apiKey) } : {}) } } : {}),
|
|
5005
|
+
...bridgeFacts(m, readiness),
|
|
5006
|
+
});
|
|
3826
5007
|
const isMaskedEcho = (v) => typeof v === 'string' && v.startsWith('••');
|
|
3827
5008
|
const maskedGlobalModels = () => {
|
|
3828
5009
|
const flagged = costUnreliableModelIds(); // §4.6 observed flag, merged for the editor's badge
|
|
5010
|
+
const readiness = new Map();
|
|
3829
5011
|
return listGlobalModels().map((m) => ({
|
|
3830
|
-
...maskedGlobalModel(m),
|
|
5012
|
+
...maskedGlobalModel(m, readiness),
|
|
3831
5013
|
...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
|
|
3832
5014
|
}));
|
|
3833
5015
|
};
|
|
5016
|
+
/** A POST/PATCH `upstream` body with a masked apiKey echo dropped ("keep"). */
|
|
5017
|
+
const upstreamInput = (u, current) => {
|
|
5018
|
+
if (!u || typeof u !== 'object' || Array.isArray(u)) return u;
|
|
5019
|
+
if (isMaskedEcho(u.apiKey)) {
|
|
5020
|
+
const keep = current && current.upstream ? current.upstream.apiKey : undefined;
|
|
5021
|
+
const { apiKey, ...rest } = u;
|
|
5022
|
+
return keep ? { ...rest, apiKey: keep } : rest;
|
|
5023
|
+
}
|
|
5024
|
+
return u;
|
|
5025
|
+
};
|
|
3834
5026
|
|
|
3835
5027
|
/** Read-only plugin model entries (design §9.7): literals masked with the
|
|
3836
5028
|
* standard masker, ${VAR} refs readable, {secret} placeholders surfaced as
|
|
@@ -3838,6 +5030,7 @@ const maskedGlobalModels = () => {
|
|
|
3838
5030
|
const pluginModelsPayload = () => {
|
|
3839
5031
|
const flagged = costUnreliableModelIds();
|
|
3840
5032
|
const statusByPlugin = new Map();
|
|
5033
|
+
const readiness = new Map();
|
|
3841
5034
|
return listPluginModels().map((m) => {
|
|
3842
5035
|
if (!statusByPlugin.has(m.plugin)) statusByPlugin.set(m.plugin, pluginModelSecretStatus(m.plugin));
|
|
3843
5036
|
const status = statusByPlugin.get(m.plugin);
|
|
@@ -3848,30 +5041,153 @@ const pluginModelsPayload = () => {
|
|
|
3848
5041
|
])),
|
|
3849
5042
|
secrets: status.filter((s) => m.secrets.includes(s.key)),
|
|
3850
5043
|
...(m.cost ? { cost: m.cost } : {}), // manifest-pinned pricing — config, never a credential
|
|
5044
|
+
...(m.upstream ? { upstream: { ...m.upstream } } : {}), // a plugin apiKey is a ${VAR} ref by validation — readable
|
|
5045
|
+
...bridgeFacts(m, readiness),
|
|
3851
5046
|
...(flagged.has(m.id.toLowerCase()) ? { costUnreliable: true } : {}),
|
|
3852
5047
|
};
|
|
3853
5048
|
});
|
|
3854
5049
|
};
|
|
3855
5050
|
|
|
3856
5051
|
app.get('/api/models', (req, res) => {
|
|
5052
|
+
const readiness = new Map();
|
|
3857
5053
|
res.json({
|
|
3858
5054
|
models: maskedGlobalModels(), plugin: pluginModelsPayload(), predefined: PREDEFINED_MODELS, efforts: EFFORTS,
|
|
3859
5055
|
hideBuiltinModels: hideBuiltinModels(), // the Models-view checkbox (#422)
|
|
5056
|
+
// Team policy catalog entries (team-policy design §8): read-only, env masked like a global's.
|
|
5057
|
+
policy: policyCatalogModels().map((m) => maskedGlobalModel(m, readiness)),
|
|
5058
|
+
providers: UPSTREAM_PROVIDERS,
|
|
3860
5059
|
});
|
|
3861
5060
|
});
|
|
3862
5061
|
|
|
3863
5062
|
app.post('/api/models', async (req, res) => {
|
|
3864
5063
|
const b = req.body || {};
|
|
3865
5064
|
try {
|
|
3866
|
-
const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env, cost: b.cost });
|
|
5065
|
+
const model = await addGlobalModel({ id: b.id, label: b.label, efforts: b.efforts, env: b.env, cost: b.cost, upstream: upstreamInput(b.upstream) });
|
|
3867
5066
|
res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
|
|
3868
5067
|
} catch (err) {
|
|
3869
5068
|
// addGlobalModel throws only on validation (empty/dup id, unknown effort,
|
|
3870
|
-
// reserved env key, non-string env value) -> client error.
|
|
5069
|
+
// reserved env key, non-string env value, malformed upstream) -> client error.
|
|
3871
5070
|
return badRequest(res, err && err.message ? err.message : String(err));
|
|
3872
5071
|
}
|
|
3873
5072
|
});
|
|
3874
5073
|
|
|
5074
|
+
// ---------------------------------------------------------------------------
|
|
5075
|
+
// Providers + the Copilot import (model-bridge-design.md §9). Never a token in
|
|
5076
|
+
// a response; the sign-in is a device-flow session the client polls.
|
|
5077
|
+
// ---------------------------------------------------------------------------
|
|
5078
|
+
const providerError = (res, err) => {
|
|
5079
|
+
const msg = err && err.message ? err.message : String(err);
|
|
5080
|
+
if (err && (err.code === 'TERMS' || err.code === 'NOT_SIGNED_IN')) return res.status(409).json({ error: msg, code: err.code });
|
|
5081
|
+
return badRequest(res, msg);
|
|
5082
|
+
};
|
|
5083
|
+
|
|
5084
|
+
app.get('/api/providers', async (req, res) => {
|
|
5085
|
+
try {
|
|
5086
|
+
res.json(await providersState({ quota: req.query.quota === '1' }));
|
|
5087
|
+
} catch (err) {
|
|
5088
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
5089
|
+
}
|
|
5090
|
+
});
|
|
5091
|
+
|
|
5092
|
+
app.patch('/api/providers/:name', async (req, res) => {
|
|
5093
|
+
try {
|
|
5094
|
+
await patchProvider(req.params.name, req.body || {});
|
|
5095
|
+
emitChanged('settings-changed');
|
|
5096
|
+
res.json(await providersState());
|
|
5097
|
+
} catch (err) {
|
|
5098
|
+
return providerError(res, err);
|
|
5099
|
+
}
|
|
5100
|
+
});
|
|
5101
|
+
|
|
5102
|
+
app.post('/api/providers/copilot/acknowledge', async (req, res) => {
|
|
5103
|
+
try {
|
|
5104
|
+
await acknowledgeTerms();
|
|
5105
|
+
res.json(await providersState());
|
|
5106
|
+
} catch (err) {
|
|
5107
|
+
return providerError(res, err);
|
|
5108
|
+
}
|
|
5109
|
+
});
|
|
5110
|
+
|
|
5111
|
+
app.post('/api/providers/copilot/login', async (req, res) => {
|
|
5112
|
+
try {
|
|
5113
|
+
res.json(await beginCopilotLogin());
|
|
5114
|
+
} catch (err) {
|
|
5115
|
+
return providerError(res, err);
|
|
5116
|
+
}
|
|
5117
|
+
});
|
|
5118
|
+
|
|
5119
|
+
app.get('/api/providers/copilot/login/:deviceCode', async (req, res) => {
|
|
5120
|
+
try {
|
|
5121
|
+
const r = await pollCopilotLogin(String(req.params.deviceCode));
|
|
5122
|
+
if (r.ok) emitChanged('settings-changed');
|
|
5123
|
+
res.json(r);
|
|
5124
|
+
} catch (err) {
|
|
5125
|
+
return providerError(res, err);
|
|
5126
|
+
}
|
|
5127
|
+
});
|
|
5128
|
+
|
|
5129
|
+
app.post('/api/providers/copilot/logout', async (req, res) => {
|
|
5130
|
+
try {
|
|
5131
|
+
await copilotLogout();
|
|
5132
|
+
emitChanged('settings-changed');
|
|
5133
|
+
res.json(await providersState());
|
|
5134
|
+
} catch (err) {
|
|
5135
|
+
return providerError(res, err);
|
|
5136
|
+
}
|
|
5137
|
+
});
|
|
5138
|
+
|
|
5139
|
+
app.get('/api/providers/copilot/models', async (req, res) => {
|
|
5140
|
+
try {
|
|
5141
|
+
res.json({ models: await copilotModelsForImport() });
|
|
5142
|
+
} catch (err) {
|
|
5143
|
+
return providerError(res, err);
|
|
5144
|
+
}
|
|
5145
|
+
});
|
|
5146
|
+
|
|
5147
|
+
// What an OpenAI-compatible endpoint serves (§8.4): llama.cpp, Ollama, LM Studio, vLLM or a
|
|
5148
|
+
// gateway, asked on its own surface so the rows carry real context windows and tool support.
|
|
5149
|
+
// ?baseUrl= overrides the provider's own, so a second local server can be browsed without
|
|
5150
|
+
// saving it first. Same reach as Test connection: the UI is loopback-only.
|
|
5151
|
+
app.get('/api/providers/openai/models', async (req, res) => {
|
|
5152
|
+
try {
|
|
5153
|
+
res.json(await endpointModelsForImport({ baseUrl: typeof req.query.baseUrl === 'string' ? req.query.baseUrl : '' }));
|
|
5154
|
+
} catch (err) {
|
|
5155
|
+
return providerError(res, err);
|
|
5156
|
+
}
|
|
5157
|
+
});
|
|
5158
|
+
|
|
5159
|
+
app.post('/api/providers/openai/import-models', async (req, res) => {
|
|
5160
|
+
const b = req.body || {};
|
|
5161
|
+
try {
|
|
5162
|
+
const result = await importEndpointModels(b.ids, { baseUrl: typeof b.baseUrl === 'string' ? b.baseUrl : '' });
|
|
5163
|
+
emitChanged('settings-changed');
|
|
5164
|
+
res.json({ ...result, models: maskedGlobalModels() });
|
|
5165
|
+
} catch (err) {
|
|
5166
|
+
return providerError(res, err);
|
|
5167
|
+
}
|
|
5168
|
+
});
|
|
5169
|
+
|
|
5170
|
+
app.post('/api/providers/:name/test', async (req, res) => {
|
|
5171
|
+
// The card tests what is ON SCREEN (§8.1): an unsaved base URL or key is sent with the request,
|
|
5172
|
+
// so "Test connection" answers for the endpoint the user is looking at, not the stored one.
|
|
5173
|
+
const b = req.body || {};
|
|
5174
|
+
res.json(await testProviderConnection(req.params.name, {
|
|
5175
|
+
baseUrl: typeof b.baseUrl === 'string' ? b.baseUrl : '',
|
|
5176
|
+
...(typeof b.apiKey === 'string' ? { apiKey: b.apiKey } : {}),
|
|
5177
|
+
}));
|
|
5178
|
+
});
|
|
5179
|
+
|
|
5180
|
+
app.post('/api/providers/copilot/import-models', async (req, res) => {
|
|
5181
|
+
const b = req.body || {};
|
|
5182
|
+
if (b.provider && b.provider !== 'copilot') return badRequest(res, 'only the copilot provider supports import');
|
|
5183
|
+
try {
|
|
5184
|
+
const result = await importCopilotModels(b.ids);
|
|
5185
|
+
res.json({ ...result, models: maskedGlobalModels() });
|
|
5186
|
+
} catch (err) {
|
|
5187
|
+
return providerError(res, err);
|
|
5188
|
+
}
|
|
5189
|
+
});
|
|
5190
|
+
|
|
3875
5191
|
// Promote a legacy per-project custom model to the global catalog (§4.9).
|
|
3876
5192
|
// Refs survive by construction — see promoteCustomModel. Registered before the
|
|
3877
5193
|
// :id routes only for readability; POST /api/models/promote shares no method
|
|
@@ -4020,7 +5336,8 @@ app.patch('/api/models/:id', async (req, res) => {
|
|
|
4020
5336
|
env = Object.fromEntries(Object.entries(env).filter(([, v]) => !isMaskedEcho(v)));
|
|
4021
5337
|
}
|
|
4022
5338
|
try {
|
|
4023
|
-
const
|
|
5339
|
+
const current = b.upstream ? listGlobalModels().find((m) => m.id.toLowerCase() === String(req.params.id).toLowerCase()) : null;
|
|
5340
|
+
const model = await updateGlobalModel(req.params.id, { label: b.label, efforts: b.efforts, env, cost: b.cost, upstream: upstreamInput(b.upstream, current) });
|
|
4024
5341
|
res.json({ model: maskedGlobalModel(model), models: maskedGlobalModels() });
|
|
4025
5342
|
} catch (err) {
|
|
4026
5343
|
// updateGlobalModel throws only on validation (unknown id, unknown effort,
|
|
@@ -4052,6 +5369,9 @@ app.get('/api/models/:id/env-value', (req, res) => {
|
|
|
4052
5369
|
app.delete('/api/models/:id', async (req, res) => {
|
|
4053
5370
|
try {
|
|
4054
5371
|
const result = await removeGlobalModelAndRefs(req.params.id);
|
|
5372
|
+
// Settings › Memory's defragment model went with the entry: open tabs repaint that card (as
|
|
5373
|
+
// the Ask remove_model path's settings-changed does).
|
|
5374
|
+
if (result.clearedMemoryDefrag) emitChanged('settings-changed');
|
|
4055
5375
|
res.json({ ...result, models: maskedGlobalModels() });
|
|
4056
5376
|
} catch (err) {
|
|
4057
5377
|
// Throws only on an unknown id -> client error.
|
|
@@ -4124,6 +5444,9 @@ function sendWorkflowShareError(res, err) {
|
|
|
4124
5444
|
// line, verbatim.
|
|
4125
5445
|
if (code === 'RESERVED_NAME') return res.status(422).json({ error: message });
|
|
4126
5446
|
if (code === 'ID_TAKEN') return res.status(409).json({ error: message, id: err.id });
|
|
5447
|
+
// P10: a script import the user has not confirmed is well-formed but conflicts
|
|
5448
|
+
// with a confirmation not yet given; `scriptNodes` are the commands to show first.
|
|
5449
|
+
if (code === 'SCRIPTS_UNCONFIRMED') return res.status(409).json({ error: message, code, scriptNodes: err.scriptNodes || [] });
|
|
4127
5450
|
if (code === 'NOT_FOUND') return res.status(404).json({ error: message });
|
|
4128
5451
|
return res.status(500).json({ error: message });
|
|
4129
5452
|
}
|
|
@@ -4149,7 +5472,15 @@ app.get('/api/workflows/:id', async (req, res) => {
|
|
|
4149
5472
|
// this is a READ (the Composer's Open): a template stranded by an agent-port
|
|
4150
5473
|
// edit must still load, or the user could never repair it. The RUN path keeps
|
|
4151
5474
|
// the graph check.
|
|
4152
|
-
|
|
5475
|
+
const wf = await assertRunnableWorkflow(req.params.id, { checkGraph: false });
|
|
5476
|
+
// Settings › Memory: the built-in reads with the pair every defragment run will use, so New
|
|
5477
|
+
// pipeline's agent rows and an Ask card's lane show — and lock — it (memory-defrag-model.mjs).
|
|
5478
|
+
if (wf && wf.id === MEMORY_DEFRAG_WORKFLOW_ID) {
|
|
5479
|
+
const stored = memoryDefragModel();
|
|
5480
|
+
const pair = stored.model ? resolveDefragModel({ stored, models: await listModels('') }) : null;
|
|
5481
|
+
return res.json(defragWorkflowView(wf, pair));
|
|
5482
|
+
}
|
|
5483
|
+
res.json(wf);
|
|
4153
5484
|
} catch (err) {
|
|
4154
5485
|
if (err && (err.code === 'NOT_FOUND' || err.code === 'ARCHIVED')) {
|
|
4155
5486
|
return res.status(404).json({ error: err.message });
|
|
@@ -4193,9 +5524,13 @@ app.post('/api/workflows/import-json', async (req, res) => {
|
|
|
4193
5524
|
const src = body.workflow && typeof body.workflow === 'object' && !Array.isArray(body.workflow) ? body.workflow : null;
|
|
4194
5525
|
if (!src) return badRequest(res, 'workflow (the exported JSON object) is required');
|
|
4195
5526
|
try {
|
|
5527
|
+
// D18: a dry run validates and lists the script commands the Import dialog must show first.
|
|
5528
|
+
const dryRun = body.dryRun === true;
|
|
4196
5529
|
const r = await importGraphWorkflow(src, {
|
|
4197
|
-
name: typeof body.name === 'string' ? body.name : undefined, agentsDir: AGENTS_DIR,
|
|
5530
|
+
name: typeof body.name === 'string' ? body.name : undefined, agentsDir: AGENTS_DIR, dryRun,
|
|
5531
|
+
acceptScripts: body.acceptScripts === true,
|
|
4198
5532
|
});
|
|
5533
|
+
if (dryRun) return res.json({ scriptNodes: r.scriptNodes, warnings: r.warnings, requestedName: r.requestedName });
|
|
4199
5534
|
return res.status(201).json(r);
|
|
4200
5535
|
} catch (err) {
|
|
4201
5536
|
return sendWorkflowShareError(res, err);
|
|
@@ -4344,7 +5679,8 @@ function sendGuardrailError(res, err) {
|
|
|
4344
5679
|
app.get('/api/guardrails', async (_req, res) => {
|
|
4345
5680
|
try {
|
|
4346
5681
|
const sets = await listGuardrailSets(); // CONV-1: await
|
|
4347
|
-
|
|
5682
|
+
// Team policy sets (gp:<id>, origin policy:<home>) sit between the built-ins and the user's own.
|
|
5683
|
+
res.json({ guardrails: [...listBuiltinGuardrailSets(), ...listPolicyGuardrailSets(), ...sets] });
|
|
4348
5684
|
} catch (err) {
|
|
4349
5685
|
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
4350
5686
|
}
|
|
@@ -4410,6 +5746,7 @@ app.delete('/api/guardrails/:id', async (req, res) => {
|
|
|
4410
5746
|
const id = req.params.id;
|
|
4411
5747
|
// Built-ins are not in the user store and must never be deleted.
|
|
4412
5748
|
if (isBuiltinGuardrailSetId(id)) return badRequest(res, 'built-in guardrail sets cannot be deleted');
|
|
5749
|
+
if (isPolicyGuardrailSetId(id)) return badRequest(res, 'team policy guardrail sets are edited on the Team policy page, not deleted here');
|
|
4413
5750
|
try {
|
|
4414
5751
|
const removed = await deleteGuardrailSet(id); // CONV-1: await; throws ReferencedError while pinned
|
|
4415
5752
|
if (!removed) return res.status(404).json({ error: 'guardrail set not found' });
|
|
@@ -4884,10 +6221,25 @@ function askValidateScope(raw) {
|
|
|
4884
6221
|
return { ok: true, scope: { pinned: true, [keys[0]]: cv.context[keys[0]] } };
|
|
4885
6222
|
}
|
|
4886
6223
|
|
|
4887
|
-
/** The system prompt of ONE Ask turn: the rules
|
|
4888
|
-
*
|
|
6224
|
+
/** The system prompt of ONE Ask turn: the rules, the catalog, and — only when the chat's
|
|
6225
|
+
* "Create and run scripts" pref is on (W20) — the scripts section with the runtimes this host
|
|
6226
|
+
* actually has (the python probe, cached 60 s). Memory is mounted, not rendered. */
|
|
4889
6227
|
async function askSystemPromptFor(catalog) {
|
|
4890
|
-
return askBuildSystemPrompt(catalog);
|
|
6228
|
+
return askBuildSystemPrompt(catalog, { scripts: await askScriptPromptInput() });
|
|
6229
|
+
}
|
|
6230
|
+
|
|
6231
|
+
/** "scheduled Sat Sep 19, 02:00 (run 1a2b…)" / "repeats: Every weekday at 02:00 (sch_…)" / "proposes: …" — or ''. */
|
|
6232
|
+
function askCardScheduleLine(b, tz = null) {
|
|
6233
|
+
if (b.state === 'scheduled' && b.scheduleId) return `repeats: ${b.sentence || ''} (${b.scheduleId})`;
|
|
6234
|
+
if (b.state === 'scheduled' && b.after) return `after ‘${b.after.title || b.after.id}’ (run ${b.runId})`;
|
|
6235
|
+
if (b.state === 'scheduled' && b.runId) {
|
|
6236
|
+
const ms = Date.parse(b.scheduledFor || '');
|
|
6237
|
+
const when = Number.isFinite(ms) ? formatInstant(ms, isValidTimeZone(tz) ? tz : Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC') : '?';
|
|
6238
|
+
return `scheduled for ${when} (run ${b.runId})`;
|
|
6239
|
+
}
|
|
6240
|
+
const s = b.card && b.card.schedule;
|
|
6241
|
+
if (b.state === 'proposed' && s) return s.kind === 'repeat' ? `proposes: ${s.sentence}` : s.kind === 'after' ? `proposes: ${s.text}` : `proposes: once at ${s.when}`;
|
|
6242
|
+
return '';
|
|
4891
6243
|
}
|
|
4892
6244
|
|
|
4893
6245
|
/** Resolve the VALIDATED client context into the server-side shape
|
|
@@ -4897,6 +6249,7 @@ async function askSystemPromptFor(catalog) {
|
|
|
4897
6249
|
async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], currentMessageId = null) {
|
|
4898
6250
|
const out = { now: new Date().toISOString() };
|
|
4899
6251
|
if (ctx.pinned === true) out.pinned = true; // #397: rendered as the [pinned by the user] marker
|
|
6252
|
+
if (ctx.timeZone) out.timeZone = ctx.timeZone; // validated IANA name; the header adds the user's clock
|
|
4900
6253
|
if (ctx.view) out.view = ctx.view;
|
|
4901
6254
|
if (ctx.diffPath) out.diffPath = ctx.diffPath; // client-supplied, already length-checked by validateClientContext
|
|
4902
6255
|
try {
|
|
@@ -4934,6 +6287,25 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
4934
6287
|
}
|
|
4935
6288
|
}
|
|
4936
6289
|
} catch { /* absent line */ }
|
|
6290
|
+
// The Team policy page's scope (validated slug): its name and, from the local cache only (no git), its home.
|
|
6291
|
+
try {
|
|
6292
|
+
if (ctx.tpScope) {
|
|
6293
|
+
const [kind, tpId] = ctx.tpScope.split(':');
|
|
6294
|
+
if (kind === 'project') {
|
|
6295
|
+
const p = (await listProjects()).find((x) => x.key === tpId);
|
|
6296
|
+
if (p) {
|
|
6297
|
+
const r = await resolveProjectPolicy(p.path, { discover: false }).catch(() => null);
|
|
6298
|
+
out.teamPolicy = { kind, id: tpId, name: p.name, home: r && r.ok ? r.home : null };
|
|
6299
|
+
}
|
|
6300
|
+
} else if (kind === 'workspace') {
|
|
6301
|
+
const ws = await readWorkspace(tpId);
|
|
6302
|
+
if (ws) {
|
|
6303
|
+
const r = await resolveWorkspacePolicy(ws, { discover: false }).catch(() => null);
|
|
6304
|
+
out.teamPolicy = { kind, id: tpId, name: ws.name, home: r && r.ok ? r.home : null };
|
|
6305
|
+
}
|
|
6306
|
+
}
|
|
6307
|
+
}
|
|
6308
|
+
} catch { /* absent line */ }
|
|
4937
6309
|
try {
|
|
4938
6310
|
if (ctx.pipelineId) {
|
|
4939
6311
|
const key = ctx.workspaceId ? `workspaces/${ctx.workspaceId}` : out.project?.key;
|
|
@@ -4968,8 +6340,12 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
4968
6340
|
// workflowId once the user saved it; a run card keeps its pre-P3 line byte for byte.
|
|
4969
6341
|
const wf = !!(b.card && b.card.type === 'workflow');
|
|
4970
6342
|
if (wf && b.state === 'building') continue; // transient (no name yet) — never worth a header line
|
|
4971
|
-
if (b.card && b.card.type === 'metrics') {
|
|
4972
|
-
cards.push({ id: b.id, type:
|
|
6343
|
+
if (b.card && (b.card.type === 'metrics' || b.card.type === 'policy')) {
|
|
6344
|
+
cards.push({ id: b.id, type: b.card.type, state: b.state, summary: b.card.summary || '' });
|
|
6345
|
+
continue;
|
|
6346
|
+
}
|
|
6347
|
+
if (b.card && b.card.type === 'schedule') {
|
|
6348
|
+
cards.push({ id: b.id, type: 'schedule', state: b.state, summary: b.card.summary || '' });
|
|
4973
6349
|
continue;
|
|
4974
6350
|
}
|
|
4975
6351
|
cards.push(wf
|
|
@@ -4980,6 +6356,9 @@ async function resolveAskContext(threadId, ctx = {}, listedAttachments = [], cur
|
|
|
4980
6356
|
: {
|
|
4981
6357
|
id: b.id, state: b.state, workflowId: b.card && b.card.workflowId,
|
|
4982
6358
|
targetName: (b.card && (b.card.projectName || b.card.workspaceName)) || '',
|
|
6359
|
+
// A scheduled (or schedule-proposing) run card says when, so the model never re-proposes it.
|
|
6360
|
+
...(askCardScheduleLine(b, ctx.timeZone) ? { schedule: askCardScheduleLine(b, ctx.timeZone) } : {}),
|
|
6361
|
+
...(b.card && b.card.source ? { task: `${b.card.source.plugin}/${b.card.source.sourceId} ${b.card.source.taskId}` } : {}),
|
|
4983
6362
|
});
|
|
4984
6363
|
}
|
|
4985
6364
|
}
|
|
@@ -5110,6 +6489,7 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
|
|
|
5110
6489
|
firstText: text,
|
|
5111
6490
|
deterministicTitle,
|
|
5112
6491
|
pinnedScope: askPinnedScope(ctx), // #397: proposal defaulting + mismatch flag
|
|
6492
|
+
timeZone: ctx.timeZone || (thread.context && thread.context.timeZone) || null, // scheduled runs: the user's clock
|
|
5113
6493
|
memoryProject: headerCtx.project ? { key: headerCtx.project.key, name: headerCtx.project.name || '' } : null, // native-rules revision: the turn mounts global + this project through --add-dir
|
|
5114
6494
|
mock: mockEnabled({}) ? { card: mockAskCard(ctx, text) } : null, // R-F
|
|
5115
6495
|
attachmentNames,
|
|
@@ -5124,7 +6504,12 @@ async function startAskTurn({ threadId: id, thread, ctx, model, effort, text, fi
|
|
|
5124
6504
|
onMemoryMutation: ({ scope }) => {
|
|
5125
6505
|
if (scope === 'global' || (typeof scope === 'string' && scope.startsWith('projects/') && PROJECT_KEY_RE.test(scope.slice('projects/'.length)))) emitMemoryChanged(scope);
|
|
5126
6506
|
},
|
|
6507
|
+
// A save_script in the MCP child is the same change a REST write makes (spec §3.3):
|
|
6508
|
+
// the open Scripts tabs drop their list and the composer marks its script list dirty.
|
|
6509
|
+
onScriptMutation: () => { emitChanged('scripts-changed', 'updated'); },
|
|
5127
6510
|
trackRun: (input, { pin } = {}) => askTrackRun(id, input, pin ?? null),
|
|
6511
|
+
// pause / resume / skip / mark-read in the MCP child: the Schedules page and the badges repaint.
|
|
6512
|
+
onScheduleMutation: () => { emitChanged('schedules-changed', 'ask'); emitChanged('notifications-changed'); },
|
|
5128
6513
|
},
|
|
5129
6514
|
});
|
|
5130
6515
|
job.turn = turn;
|
|
@@ -5378,15 +6763,20 @@ async function startWorkflowEventTurn(threadId, block, { declined = false, thenR
|
|
|
5378
6763
|
return failedEventTurn(threadId, { error: r.error, status: r.status, ...(r.budget ? { budget: r.budget } : {}) });
|
|
5379
6764
|
}
|
|
5380
6765
|
|
|
5381
|
-
/** The metrics card's event turn: the synthetic notice row + the
|
|
6766
|
+
/** The metrics / policy / schedule card's event turn: the synthetic notice row + the
|
|
6767
|
+
* "[worca event] <type> card …" prompt (same queueing as workflow cards). */
|
|
5382
6768
|
async function startMetricsEventTurn(threadId, block) {
|
|
5383
6769
|
const thread = askGetThread(threadId);
|
|
5384
6770
|
if (!thread) return null;
|
|
5385
6771
|
const card = block.card || {};
|
|
5386
6772
|
const state = block.state === 'declined' ? 'declined' : block.state === 'failed' ? 'failed' : 'applied';
|
|
5387
6773
|
const result = card.result || null;
|
|
5388
|
-
|
|
5389
|
-
const
|
|
6774
|
+
// One event turn for every non-workflow card; the type picks the wording. Metrics is the fallback.
|
|
6775
|
+
const kind = card.type === 'policy' || card.type === 'schedule' || card.type === 'model' ? card.type : 'metrics';
|
|
6776
|
+
const eventPrompt = { policy: policyEventPrompt, schedule: scheduleEventPrompt, model: modelEventPrompt, metrics: metricsEventPrompt }[kind];
|
|
6777
|
+
const noticeText = { policy: policyNoticeText, schedule: scheduleNoticeText, model: modelNoticeText, metrics: metricsNoticeText }[kind];
|
|
6778
|
+
const text = eventPrompt({ cardId: block.id, state, card, result });
|
|
6779
|
+
const notice = noticeText({ state, card, result });
|
|
5390
6780
|
let mv = await validateModelEffort(thread.model, thread.effort);
|
|
5391
6781
|
if (!mv.ok) {
|
|
5392
6782
|
const d = (await askCatalog({ withSecrets: false })).default;
|
|
@@ -5420,10 +6810,34 @@ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
|
|
|
5420
6810
|
const body = req.body || {};
|
|
5421
6811
|
const found = askFindCard(id, cardId);
|
|
5422
6812
|
if (!found) return res.status(404).json({ error: 'card not found' });
|
|
5423
|
-
if (found.block.card && found.block.card.type === '
|
|
5424
|
-
//
|
|
5425
|
-
//
|
|
5426
|
-
//
|
|
6813
|
+
if (found.block.card && found.block.card.type === 'schedule') {
|
|
6814
|
+
// Schedule card (docs/scheduled-runs.md "Ask Worca"): proposed → applied | failed | declined. The change —
|
|
6815
|
+
// start now, move, edit, cancel, delete — happens HERE, behind the click, through the same scheduleVerb
|
|
6816
|
+
// the Schedules page uses; never in the model's tool.
|
|
6817
|
+
if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
|
|
6818
|
+
if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
6819
|
+
if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
|
|
6820
|
+
if (body.state === 'declined') {
|
|
6821
|
+
const block = flipCard(id, cardId, { state: 'declined' });
|
|
6822
|
+
if (!block) return res.status(409).json({ error: 'card vanished' });
|
|
6823
|
+
const turn = await startMetricsEventTurn(id, block);
|
|
6824
|
+
return res.json({ block, turn });
|
|
6825
|
+
}
|
|
6826
|
+
askCardBusy.add(cardId);
|
|
6827
|
+
let block;
|
|
6828
|
+
try {
|
|
6829
|
+
let result;
|
|
6830
|
+
try { result = await applyScheduleCard(found.block.card); }
|
|
6831
|
+
catch (err) { result = { ok: false, error: err && err.message ? err.message : String(err) }; }
|
|
6832
|
+
block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
|
|
6833
|
+
} finally { askCardBusy.delete(cardId); }
|
|
6834
|
+
if (!block) return res.status(409).json({ error: 'card vanished' });
|
|
6835
|
+
const turn = await startMetricsEventTurn(id, block);
|
|
6836
|
+
return res.json({ block, turn });
|
|
6837
|
+
}
|
|
6838
|
+
if (found.block.card && found.block.card.type === 'model') {
|
|
6839
|
+
// Model card (docs/models.md "Ask Worca"): proposed → applied | failed | declined. The catalog / provider
|
|
6840
|
+
// write happens HERE, behind the click, through the same setters the Models view uses — each re-validates.
|
|
5427
6841
|
if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
|
|
5428
6842
|
if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
5429
6843
|
if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
|
|
@@ -5437,7 +6851,34 @@ app.post('/api/ask/threads/:id/cards/:cardId', async (req, res) => {
|
|
|
5437
6851
|
let block;
|
|
5438
6852
|
try {
|
|
5439
6853
|
let result;
|
|
5440
|
-
try { result = await
|
|
6854
|
+
try { result = await applyModelChange(found.block.card); emitChanged('settings-changed'); }
|
|
6855
|
+
catch (err) { result = { ok: false, error: err && err.message ? err.message : String(err) }; }
|
|
6856
|
+
block = flipCard(id, cardId, result.ok ? { state: 'applied', card: { result } } : { state: 'failed', error: result.error, card: { result } });
|
|
6857
|
+
} finally { askCardBusy.delete(cardId); }
|
|
6858
|
+
if (!block) return res.status(409).json({ error: 'card vanished' });
|
|
6859
|
+
const turn = await startMetricsEventTurn(id, block);
|
|
6860
|
+
return res.json({ block, turn });
|
|
6861
|
+
}
|
|
6862
|
+
if (found.block.card && (found.block.card.type === 'metrics' || found.block.card.type === 'policy')) {
|
|
6863
|
+
// Metrics / policy card (docs/team-metrics.md, docs/team-policy.md "Ask Worca"): proposed → applied | failed |
|
|
6864
|
+
// declined. The change is the outward-facing part — a branch on origin, a commit to the team's policy, a
|
|
6865
|
+
// marker on another repo, this machine's switch, the workspace's home — so it happens HERE, behind the
|
|
6866
|
+
// click, never in the model's tool.
|
|
6867
|
+
const apply = found.block.card.type === 'policy' ? applyPolicyChange : applyMetricsChange;
|
|
6868
|
+
if (body.state !== 'applied' && body.state !== 'declined') return badRequest(res, 'state must be "applied" or "declined"');
|
|
6869
|
+
if (found.block.state !== 'proposed') return res.status(409).json({ error: `card is ${found.block.state}` });
|
|
6870
|
+
if (askCardBusy.has(cardId)) return res.status(409).json({ error: 'card is being applied' });
|
|
6871
|
+
if (body.state === 'declined') {
|
|
6872
|
+
const block = flipCard(id, cardId, { state: 'declined' });
|
|
6873
|
+
if (!block) return res.status(409).json({ error: 'card vanished' });
|
|
6874
|
+
const turn = await startMetricsEventTurn(id, block);
|
|
6875
|
+
return res.json({ block, turn });
|
|
6876
|
+
}
|
|
6877
|
+
askCardBusy.add(cardId);
|
|
6878
|
+
let block;
|
|
6879
|
+
try {
|
|
6880
|
+
let result;
|
|
6881
|
+
try { result = await apply(found.block.card); }
|
|
5441
6882
|
catch (err) {
|
|
5442
6883
|
result = { ok: false, error: err && err.message ? err.message : String(err), code: (err && err.code) || 'ERROR', ...(err && err.hint ? { hint: err.hint } : {}), ...(err && err.stderr ? { stderr: String(err.stderr).slice(0, 2000) } : {}) };
|
|
5443
6884
|
}
|
|
@@ -5514,10 +6955,22 @@ function agentErrorStatus(code) {
|
|
|
5514
6955
|
if (code === 'NOT_FOUND') return 404;
|
|
5515
6956
|
if (code === 'BAD_REQUEST') return 400;
|
|
5516
6957
|
if (code === 'PLUGIN') return 400;
|
|
6958
|
+
// A declared ask form that fails gate 1 is a well-formed request the server
|
|
6959
|
+
// understood and refused on content — the same 422 POST /api/answer uses for a
|
|
6960
|
+
// gate-3 failure (ask-forms spec §5, §11).
|
|
6961
|
+
if (code === 'ASK_FORM') return 422;
|
|
5517
6962
|
if (code === 'BUILTIN' || code === 'DUPLICATE' || code === 'REFERENCED') return 409;
|
|
5518
6963
|
return 500;
|
|
5519
6964
|
}
|
|
5520
6965
|
|
|
6966
|
+
/** The error body: `{ error }` for every failure, plus the structured `errors`
|
|
6967
|
+
* list when the store produced one (gate 1). Never adds an empty `errors`. */
|
|
6968
|
+
function agentErrorBody(err) {
|
|
6969
|
+
const body = { error: err && err.message ? err.message : String(err) };
|
|
6970
|
+
if (Array.isArray(err?.errors) && err.errors.length) body.errors = err.errors;
|
|
6971
|
+
return body;
|
|
6972
|
+
}
|
|
6973
|
+
|
|
5521
6974
|
/**
|
|
5522
6975
|
* Fire-and-forget agent generation (mirrors startScan). Mints genId, registers
|
|
5523
6976
|
* a kind:'agentgen' entry in the SAME runs Map, wires its agentgen-* events,
|
|
@@ -5619,7 +7072,7 @@ app.post('/api/agents', async (req, res) => {
|
|
|
5619
7072
|
const created = await createAgent({ meta: body.meta, markdown: body.markdown });
|
|
5620
7073
|
res.status(201).json(created);
|
|
5621
7074
|
} catch (err) {
|
|
5622
|
-
res.status(agentErrorStatus(err && err.code)).json(
|
|
7075
|
+
res.status(agentErrorStatus(err && err.code)).json(agentErrorBody(err));
|
|
5623
7076
|
}
|
|
5624
7077
|
});
|
|
5625
7078
|
|
|
@@ -5630,7 +7083,7 @@ app.put('/api/agents/:key', async (req, res) => {
|
|
|
5630
7083
|
try {
|
|
5631
7084
|
res.json(await updateAgent(key, { meta: body.meta, markdown: body.markdown }));
|
|
5632
7085
|
} catch (err) {
|
|
5633
|
-
res.status(agentErrorStatus(err && err.code)).json(
|
|
7086
|
+
res.status(agentErrorStatus(err && err.code)).json(agentErrorBody(err));
|
|
5634
7087
|
}
|
|
5635
7088
|
});
|
|
5636
7089
|
|
|
@@ -5640,10 +7093,269 @@ app.delete('/api/agents/:key', async (req, res) => {
|
|
|
5640
7093
|
try {
|
|
5641
7094
|
res.json(await deleteAgent(key));
|
|
5642
7095
|
} catch (err) {
|
|
5643
|
-
res.status(agentErrorStatus(err && err.code)).json(
|
|
7096
|
+
res.status(agentErrorStatus(err && err.code)).json(agentErrorBody(err));
|
|
7097
|
+
}
|
|
7098
|
+
});
|
|
7099
|
+
|
|
7100
|
+
// ---------------------------------------------------------------------------
|
|
7101
|
+
// /api/scripts* -> the script registry (spec §8.4): built-in scripts/ + the user
|
|
7102
|
+
// layer + enabled plugins, D16-filtered against the agent registry. The write half
|
|
7103
|
+
// (CRUD, duplicate, cases) is below, delegated to src/core/script-store.mjs.
|
|
7104
|
+
// ---------------------------------------------------------------------------
|
|
7105
|
+
function scriptRegistryNow() {
|
|
7106
|
+
return loadScriptRegistry({ agentKeys: Object.keys(loadAgentRegistry(AGENTS_DIR)) });
|
|
7107
|
+
}
|
|
7108
|
+
|
|
7109
|
+
/**
|
|
7110
|
+
* Workbench spec §7 / W17: a python card this host cannot run is flagged ON THE
|
|
7111
|
+
* WIRE, so the composer's V4 names it the moment the card is placed and the
|
|
7112
|
+
* Scripts list can chip it. The probe is 60 s-cached, so this costs at most one
|
|
7113
|
+
* spawn a minute. The REGISTRY is deliberately never stamped: it is loaded
|
|
7114
|
+
* synchronously and the probe is not, and a run must be gated by its own
|
|
7115
|
+
* preflight — with a fresh probe, at run time — not by a flag baked into a
|
|
7116
|
+
* snapshot taken when some browser last asked for a list.
|
|
7117
|
+
*/
|
|
7118
|
+
async function stampRuntimeMissing(list) {
|
|
7119
|
+
if (!list.some((m) => m.runtime === 'python')) return list;
|
|
7120
|
+
const probe = await probePython();
|
|
7121
|
+
if (probe.ok) return list;
|
|
7122
|
+
return list.map((m) => (m.runtime === 'python' ? { ...m, runtimeMissing: true } : m));
|
|
7123
|
+
}
|
|
7124
|
+
|
|
7125
|
+
app.get('/api/scripts', async (req, res) => {
|
|
7126
|
+
try {
|
|
7127
|
+
// listScripts() IS the registry, in the same order, with caseCount stamped
|
|
7128
|
+
// (the list card's case chip). scriptRegistryNow() stays for the graph
|
|
7129
|
+
// routes, which need the raw index for registryPortsFn.
|
|
7130
|
+
res.json({ scripts: await stampRuntimeMissing(await listScripts()) });
|
|
7131
|
+
} catch (err) {
|
|
7132
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
7133
|
+
}
|
|
7134
|
+
});
|
|
7135
|
+
|
|
7136
|
+
// Literal segments are registered BEFORE /api/scripts/:key, so the param route
|
|
7137
|
+
// can never swallow them (the POST /api/agents/generate rule).
|
|
7138
|
+
app.get('/api/scripts/runtimes', async (req, res) => {
|
|
7139
|
+
// C8: the picker is wired once, against the real shape. The python arm is the
|
|
7140
|
+
// real probe (workbench spec §7; never rejects, 60 s-cached); node and shell are
|
|
7141
|
+
// guaranteed by the host itself.
|
|
7142
|
+
res.json({
|
|
7143
|
+
node: { ok: true, version: process.version },
|
|
7144
|
+
shell: { ok: true, path: process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh' },
|
|
7145
|
+
python: pythonRuntimeState(await probePython()),
|
|
7146
|
+
});
|
|
7147
|
+
});
|
|
7148
|
+
|
|
7149
|
+
/**
|
|
7150
|
+
* Fire-and-forget bench run (mirrors startAgentGen). Mints nothing itself: the
|
|
7151
|
+
* engine owns the id and tags every event with it, so the runs-Map key, the
|
|
7152
|
+
* buffered events and the `?benchId=` replay all agree on one id.
|
|
7153
|
+
* @returns {string} benchId
|
|
7154
|
+
*/
|
|
7155
|
+
function startScriptBench(request) {
|
|
7156
|
+
const orch = createBench(request);
|
|
7157
|
+
const benchId = orch.id;
|
|
7158
|
+
const entry = {
|
|
7159
|
+
id: benchId, benchId, orch, kind: 'scriptbench', projectDir: null,
|
|
7160
|
+
title: `bench: ${request.key}`, status: 'running',
|
|
7161
|
+
startedAt: new Date().toISOString(), events: [], pendingQuestion: null, result: null, seq: 0,
|
|
7162
|
+
};
|
|
7163
|
+
runs.set(benchId, entry);
|
|
7164
|
+
// Nothing else ever deletes a bench entry (runs.delete fires for pipeline
|
|
7165
|
+
// lineages only), and Run is the page's main verb: keep the newest few so the
|
|
7166
|
+
// output route and a reconnect can still read them, drop the rest with their
|
|
7167
|
+
// <= 5000 buffered lines and 256 KiB-per-output results.
|
|
7168
|
+
const benches = [...runs.values()].filter((e) => e.kind === 'scriptbench');
|
|
7169
|
+
for (const old of benches.slice(0, Math.max(0, benches.length - MAX_BENCH_ENTRIES))) {
|
|
7170
|
+
if (old.status !== 'running') runs.delete(old.id);
|
|
7171
|
+
}
|
|
7172
|
+
wireScriptBench(entry);
|
|
7173
|
+
|
|
7174
|
+
Promise.resolve()
|
|
7175
|
+
.then(() => orch.run())
|
|
7176
|
+
.catch((err) => {
|
|
7177
|
+
// run() never throws (it emits scriptbench-error); this is startScan's
|
|
7178
|
+
// defensive backstop, surfacing an unexpected throw as a tagged error.
|
|
7179
|
+
// It carries the entry's next `seq` like every other frame (C17), or a
|
|
7180
|
+
// subscribe replay would deliver this one twice.
|
|
7181
|
+
entry.seq = (entry.seq || 0) + 1;
|
|
7182
|
+
const event = { benchId, seq: entry.seq, type: 'scriptbench-error', message: err && err.message ? err.message : String(err), code: null };
|
|
7183
|
+
entry.status = 'error';
|
|
7184
|
+
entry.events.push(event);
|
|
7185
|
+
broadcast(event);
|
|
7186
|
+
});
|
|
7187
|
+
|
|
7188
|
+
return benchId;
|
|
7189
|
+
}
|
|
7190
|
+
|
|
7191
|
+
// The §4.2 request rides through untouched: the engine validates it and reports
|
|
7192
|
+
// every refusal (unknown key, bad param, cap hit) as ONE scriptbench-error on
|
|
7193
|
+
// the socket. Only the key shape is checked here, so a malformed :key-shaped
|
|
7194
|
+
// value can never reach the store.
|
|
7195
|
+
app.post('/api/scripts/bench', (req, res) => {
|
|
7196
|
+
const body = req.body || {};
|
|
7197
|
+
const key = typeof body.key === 'string' ? body.key.trim() : '';
|
|
7198
|
+
if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
|
|
7199
|
+
try {
|
|
7200
|
+
res.json({ benchId: startScriptBench({ ...body, key }) });
|
|
7201
|
+
} catch (err) {
|
|
7202
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
7203
|
+
}
|
|
7204
|
+
});
|
|
7205
|
+
|
|
7206
|
+
// Idempotent stop (the /api/agents/generate/stop shape): an unknown or finished
|
|
7207
|
+
// bench still answers ok.
|
|
7208
|
+
app.post('/api/scripts/bench/stop', (req, res) => {
|
|
7209
|
+
const benchId = req.body && typeof req.body.benchId === 'string' ? req.body.benchId : '';
|
|
7210
|
+
const entry = benchId ? runs.get(benchId) : null;
|
|
7211
|
+
if (entry && entry.kind === 'scriptbench' && entry.orch && typeof entry.orch.stop === 'function') {
|
|
7212
|
+
try { entry.orch.stop(); } catch { /* best-effort */ }
|
|
7213
|
+
entry.status = 'stopped';
|
|
7214
|
+
}
|
|
7215
|
+
res.json({ ok: true });
|
|
7216
|
+
});
|
|
7217
|
+
|
|
7218
|
+
// The full text of one output (the WS result carries at most 256 KiB). The file
|
|
7219
|
+
// PATH comes from the entry's stored result — never from the URL — so this route
|
|
7220
|
+
// cannot be walked; `:port` is only a lookup key into that result.
|
|
7221
|
+
app.get('/api/scripts/bench/:benchId/output/:port', async (req, res) => {
|
|
7222
|
+
const entry = runs.get(req.params.benchId);
|
|
7223
|
+
if (!entry || entry.kind !== 'scriptbench' || !entry.result) return res.status(404).json({ error: 'bench not found' });
|
|
7224
|
+
if (!PORT_ID_RE.test(req.params.port)) return res.status(404).json({ error: 'output not found' });
|
|
7225
|
+
const caseId = typeof req.query.caseId === 'string' ? req.query.caseId : '';
|
|
7226
|
+
// A Run-all result is a list of per-case results (§4.3), so it needs ?caseId=.
|
|
7227
|
+
const result = Array.isArray(entry.result.cases)
|
|
7228
|
+
? (entry.result.cases.find((c) => c.caseId === caseId) || {}).result
|
|
7229
|
+
: entry.result;
|
|
7230
|
+
// hasOwn: PORT_ID_RE admits `constructor` / `toString`, which a plain lookup would find on Object.prototype.
|
|
7231
|
+
const out = result && result.outputs && Object.hasOwn(result.outputs, req.params.port) ? result.outputs[req.params.port] : null;
|
|
7232
|
+
if (!out || !out.path) return res.status(404).json({ error: 'output not found' });
|
|
7233
|
+
// STREAMED, not read into a string: a bench output is whatever the script
|
|
7234
|
+
// wrote. readFile() buffered the whole file (a 600 MB log spiked the server by
|
|
7235
|
+
// half a gigabyte) and past ~512 MiB threw "Invalid string length", which this
|
|
7236
|
+
// catch then reported as a missing output.
|
|
7237
|
+
let st;
|
|
7238
|
+
try { st = await fsp.stat(out.path); } catch { return res.status(404).json({ error: 'output not found' }); }
|
|
7239
|
+
if (!st.isFile()) return res.status(404).json({ error: 'output not found' });
|
|
7240
|
+
res.type('text/plain; charset=utf-8').set('Content-Length', String(st.size));
|
|
7241
|
+
const stream = fs.createReadStream(out.path);
|
|
7242
|
+
stream.on('error', () => { res.destroy(); });
|
|
7243
|
+
res.on('close', () => stream.destroy());
|
|
7244
|
+
stream.pipe(res);
|
|
7245
|
+
});
|
|
7246
|
+
|
|
7247
|
+
|
|
7248
|
+
app.get('/api/scripts/:key', async (req, res) => {
|
|
7249
|
+
const key = req.params.key;
|
|
7250
|
+
if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
|
|
7251
|
+
try {
|
|
7252
|
+
const data = await readScript(key);
|
|
7253
|
+
if (!data) return res.status(404).json({ error: 'script not found' });
|
|
7254
|
+
// The meta is spread FLAT (the Scripts page and the composer read `key`,
|
|
7255
|
+
// `runtime`, `ports` off the top level); the store's reads ride beside it.
|
|
7256
|
+
res.json({
|
|
7257
|
+
...data.meta,
|
|
7258
|
+
source: data.source,
|
|
7259
|
+
sourceWin32: data.sourceWin32,
|
|
7260
|
+
sourcePath: data.sourcePath,
|
|
7261
|
+
sourceTruncated: data.sourceTruncated,
|
|
7262
|
+
cases: data.cases,
|
|
7263
|
+
userCases: data.userCases,
|
|
7264
|
+
casesWritable: data.casesWritable,
|
|
7265
|
+
});
|
|
7266
|
+
} catch (err) {
|
|
7267
|
+
res.status(500).json({ error: err && err.message ? err.message : String(err) });
|
|
7268
|
+
}
|
|
7269
|
+
});
|
|
7270
|
+
|
|
7271
|
+
|
|
7272
|
+
// ---------------------------------------------------------------------------
|
|
7273
|
+
// The write half (spec §3.3–§3.4): user-layer CRUD, duplicate and cases,
|
|
7274
|
+
// delegated to src/core/script-store.mjs. The store's err.code vocabulary IS
|
|
7275
|
+
// the agent store's, so agentErrorStatus maps it unchanged. `by: 'ui'` stamps
|
|
7276
|
+
// every save from this process (W19); the CLI and Ask Worca call core directly
|
|
7277
|
+
// with their own stamp. Each mutation pokes every open tab with the bare
|
|
7278
|
+
// `scripts-changed` broadcast, so a script saved elsewhere appears without a
|
|
7279
|
+
// reload — the client drops its cached list and marks the palette dirty.
|
|
7280
|
+
// ---------------------------------------------------------------------------
|
|
7281
|
+
const STORE_ERROR_CODES = new Set(['NOT_FOUND', 'BAD_REQUEST', 'PLUGIN', 'BUILTIN', 'DUPLICATE', 'REFERENCED']);
|
|
7282
|
+
// Only the store's OWN vocabulary is quoted back to the page. An fs failure
|
|
7283
|
+
// (EACCES on the scripts dir, a full disk) carries the absolute home path in its
|
|
7284
|
+
// message, which no banner may surface (agent-store.mjs#propagateToVariants).
|
|
7285
|
+
const scriptError = (res, err) => {
|
|
7286
|
+
const code = err && err.code;
|
|
7287
|
+
return res
|
|
7288
|
+
.status(agentErrorStatus(code))
|
|
7289
|
+
.json({ error: STORE_ERROR_CODES.has(code) && err.message ? err.message : `the script store failed (${code || 'unknown error'})` });
|
|
7290
|
+
};
|
|
7291
|
+
|
|
7292
|
+
app.post('/api/scripts', async (req, res) => {
|
|
7293
|
+
const body = req.body || {};
|
|
7294
|
+
try {
|
|
7295
|
+
const created = await createScript({ meta: body.meta, source: body.source, sourceWin32: body.sourceWin32, by: 'ui' });
|
|
7296
|
+
emitChanged('scripts-changed', 'created');
|
|
7297
|
+
res.status(201).json(created);
|
|
7298
|
+
} catch (err) {
|
|
7299
|
+
scriptError(res, err);
|
|
7300
|
+
}
|
|
7301
|
+
});
|
|
7302
|
+
|
|
7303
|
+
app.put('/api/scripts/:key', async (req, res) => {
|
|
7304
|
+
const key = req.params.key;
|
|
7305
|
+
if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
|
|
7306
|
+
const body = req.body || {};
|
|
7307
|
+
try {
|
|
7308
|
+
const updated = await updateScript(key, { meta: body.meta, source: body.source, sourceWin32: body.sourceWin32, by: 'ui' });
|
|
7309
|
+
emitChanged('scripts-changed', 'updated');
|
|
7310
|
+
res.json(updated);
|
|
7311
|
+
} catch (err) {
|
|
7312
|
+
scriptError(res, err);
|
|
7313
|
+
}
|
|
7314
|
+
});
|
|
7315
|
+
|
|
7316
|
+
app.delete('/api/scripts/:key', async (req, res) => {
|
|
7317
|
+
const key = req.params.key;
|
|
7318
|
+
if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
|
|
7319
|
+
try {
|
|
7320
|
+
const r = await deleteScript(key);
|
|
7321
|
+
emitChanged('scripts-changed', 'deleted');
|
|
7322
|
+
res.json(r);
|
|
7323
|
+
} catch (err) {
|
|
7324
|
+
scriptError(res, err);
|
|
5644
7325
|
}
|
|
5645
7326
|
});
|
|
5646
7327
|
|
|
7328
|
+
app.post('/api/scripts/:key/duplicate', async (req, res) => {
|
|
7329
|
+
const key = req.params.key;
|
|
7330
|
+
if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
|
|
7331
|
+
const newKey = req.body && typeof req.body.newKey === 'string' ? req.body.newKey.trim() : '';
|
|
7332
|
+
if (!newKey) return badRequest(res, 'newKey is required');
|
|
7333
|
+
try {
|
|
7334
|
+
const copy = await duplicateScript(key, newKey, 'ui');
|
|
7335
|
+
emitChanged('scripts-changed', 'created');
|
|
7336
|
+
res.status(201).json(copy);
|
|
7337
|
+
} catch (err) {
|
|
7338
|
+
scriptError(res, err);
|
|
7339
|
+
}
|
|
7340
|
+
});
|
|
7341
|
+
|
|
7342
|
+
app.put('/api/scripts/:key/cases', async (req, res) => {
|
|
7343
|
+
const key = req.params.key;
|
|
7344
|
+
if (!SCRIPT_KEY_RE.test(key)) return res.status(404).json({ error: 'script not found' });
|
|
7345
|
+
const cases = req.body && Array.isArray(req.body.cases) ? req.body.cases : null;
|
|
7346
|
+
if (!cases) return badRequest(res, 'cases must be an array');
|
|
7347
|
+
try {
|
|
7348
|
+
// W18: for a built-in or plugin key this lands in the user layer as an
|
|
7349
|
+
// overlay — a tests file with no meta beside it.
|
|
7350
|
+
const r = await writeCases(key, cases);
|
|
7351
|
+
emitChanged('scripts-changed', 'cases');
|
|
7352
|
+
res.json(r);
|
|
7353
|
+
} catch (err) {
|
|
7354
|
+
scriptError(res, err);
|
|
7355
|
+
}
|
|
7356
|
+
});
|
|
7357
|
+
|
|
7358
|
+
|
|
5647
7359
|
// ---------------------------------------------------------------------------
|
|
5648
7360
|
// /api/plugins* -> plugin lifecycle, delegated to src/core/plugin-store.mjs /
|
|
5649
7361
|
// plugin-repo.mjs / plugin-config.mjs (spec §6). Thin wrappers: all policy
|
|
@@ -5710,13 +7422,19 @@ function readInstalledManifest(name) {
|
|
|
5710
7422
|
}
|
|
5711
7423
|
}
|
|
5712
7424
|
|
|
5713
|
-
app.get('/api/plugins', (req, res) => {
|
|
7425
|
+
app.get('/api/plugins', async (req, res) => {
|
|
5714
7426
|
try {
|
|
5715
7427
|
const mkts = readMarketplaces().marketplaces;
|
|
7428
|
+
const rows = listInstalledPlugins();
|
|
7429
|
+
// The python notice (spec §8.1) is a fact about THIS host: resolved once per
|
|
7430
|
+
// request, and only when some plugin ships a python script (the probe caches 60 s).
|
|
7431
|
+
const anyPython = rows.some((p) => Number((p.scriptRuntimes || {}).python) > 0);
|
|
7432
|
+
const notice = anyPython ? await pythonNoticeFor([{ runtime: 'python' }]) : null;
|
|
5716
7433
|
res.json({
|
|
5717
|
-
plugins:
|
|
7434
|
+
plugins: rows.map((p) => ({
|
|
5718
7435
|
...p,
|
|
5719
7436
|
marketplaceName: p.marketplace && mkts[p.marketplace] ? mkts[p.marketplace].name : null,
|
|
7437
|
+
pythonMissing: !!(notice && Number((p.scriptRuntimes || {}).python) > 0),
|
|
5720
7438
|
})),
|
|
5721
7439
|
orphans: listOrphanPluginData(),
|
|
5722
7440
|
});
|
|
@@ -6357,7 +8075,7 @@ app.post('/api/pipelines/:id/report-issue', async (req, res) => {
|
|
|
6357
8075
|
});
|
|
6358
8076
|
|
|
6359
8077
|
// ---------------------------------------------------------------------------
|
|
6360
|
-
// Install logic (mirrors
|
|
8078
|
+
// Install logic (mirrors tools/install.mjs): copy agents/*.md and
|
|
6361
8079
|
// skills/worca/** into <projectDir>/.claude/...
|
|
6362
8080
|
// ---------------------------------------------------------------------------
|
|
6363
8081
|
async function installAgents(projectDir) {
|
|
@@ -6438,9 +8156,9 @@ async function copyDir(srcDir, destDir, baseForRel, copiedOut) {
|
|
|
6438
8156
|
* @param {Array<{name?:string, dataBase64?:string}>} list
|
|
6439
8157
|
* @returns {Promise<string[]>}
|
|
6440
8158
|
*/
|
|
6441
|
-
async function writeExtras(runId, list) {
|
|
8159
|
+
async function writeExtras(runId, list, dirOverride = null) {
|
|
6442
8160
|
if (!Array.isArray(list) || list.length === 0) return [];
|
|
6443
|
-
const dir = path.join(os.tmpdir(), `orchestrator-extras-${runId}`);
|
|
8161
|
+
const dir = dirOverride || path.join(os.tmpdir(), `orchestrator-extras-${runId}`);
|
|
6444
8162
|
await fsp.mkdir(dir, { recursive: true });
|
|
6445
8163
|
const out = [];
|
|
6446
8164
|
let i = 0;
|
|
@@ -6553,7 +8271,7 @@ app.use((err, _req, res, next) => {
|
|
|
6553
8271
|
* sweep keeps its own console default.
|
|
6554
8272
|
*/
|
|
6555
8273
|
export async function bootMaintenance({ log } = {}) {
|
|
6556
|
-
const summary = { reconciled: 0, sweptV1: 0, runRoots: null, legacy: null, ask: null, askWorktrees: null };
|
|
8274
|
+
const summary = { reconciled: 0, sweptV1: 0, runRoots: null, legacy: null, ask: null, askWorktrees: null, bench: null };
|
|
6557
8275
|
const sink = (scope) => (typeof log === 'function' ? (level, msg) => log(scope, level, msg) : undefined);
|
|
6558
8276
|
|
|
6559
8277
|
// Runs left 'running' by a previous process that died before writing a terminal
|
|
@@ -6645,6 +8363,19 @@ export async function bootMaintenance({ log } = {}) {
|
|
|
6645
8363
|
} catch (err) {
|
|
6646
8364
|
console.error(`[worca-ui] ask-worktree sweep failed: ${err && err.message ? err.message : err}`);
|
|
6647
8365
|
}
|
|
8366
|
+
|
|
8367
|
+
// Script bench folders (workbench W11): the newest folder per script key
|
|
8368
|
+
// outlives its run so the output tabs can still read it; 24 h later it is
|
|
8369
|
+
// junk. os.tmpdir() would have been cleaned under us on macOS, hence
|
|
8370
|
+
// <worcaHome>/bench and this sweep.
|
|
8371
|
+
try {
|
|
8372
|
+
const removed = await sweepBenchDirs(benchRoot());
|
|
8373
|
+
summary.bench = { removed: removed.length };
|
|
8374
|
+
if (removed.length) console.log(`[worca-ui] bench sweep: removed ${removed.length} folder(s)`);
|
|
8375
|
+
} catch (err) {
|
|
8376
|
+
summary.bench = { removed: 0 };
|
|
8377
|
+
console.error(`[worca-ui] bench sweep failed: ${err && err.message ? err.message : err}`);
|
|
8378
|
+
}
|
|
6648
8379
|
return summary;
|
|
6649
8380
|
}
|
|
6650
8381
|
|
|
@@ -6710,15 +8441,38 @@ if (isMain) {
|
|
|
6710
8441
|
}
|
|
6711
8442
|
try { startTeamMetricsBackground({ log: (m) => console.warn(m) }); }
|
|
6712
8443
|
catch (err) { console.warn(`[worca-ui] team metrics background: ${err?.message || err}`); }
|
|
8444
|
+
// Model bridge (model-bridge-design.md §4.1): up before the first bridged
|
|
8445
|
+
// spawn so resolveModelEnv's synchronous start is the exception, not the rule.
|
|
8446
|
+
startBridge({ log: (m) => console.warn(m) }).catch((err) => console.warn(`[worca-ui] model bridge: ${err?.message || err}`));
|
|
8447
|
+
// Team policy discovery (design §9), then the marketplace seeding a policy asks for — metadata
|
|
8448
|
+
// only (a git archive), never an install; installs go through the setup checklist.
|
|
8449
|
+
try {
|
|
8450
|
+
startTeamPolicyBackground({
|
|
8451
|
+
log: (m) => console.warn(m),
|
|
8452
|
+
onTick: async () => {
|
|
8453
|
+
const scopes = await listPolicyScopes();
|
|
8454
|
+
const docs = [];
|
|
8455
|
+
for (const s of scopes.projects) if (s.home && !docs.some((d) => d.slug === s.home)) {
|
|
8456
|
+
const r = await resolveProjectPolicy(s.path, { discover: false }).catch(() => null);
|
|
8457
|
+
if (r?.ok) docs.push({ slug: r.home, doc: r.doc });
|
|
8458
|
+
}
|
|
8459
|
+
const seeded = await seedPolicyMarketplaces(docs);
|
|
8460
|
+
for (const s of seeded) if (s.added) { console.warn(`[worca-ui] team policy: added marketplace ${s.url} (asked for by ${s.homes.join(', ')})`); emitChanged('plugins-changed', 'marketplace-seeded'); }
|
|
8461
|
+
},
|
|
8462
|
+
});
|
|
8463
|
+
} catch (err) { console.warn(`[worca-ui] team policy background: ${err?.message || err}`); }
|
|
8464
|
+
// Scheduled runs: boot catch-up + the 30 s tick (the server IS the scheduler).
|
|
8465
|
+
try { startScheduler(); } catch (err) { console.warn(`[worca-ui] scheduler: ${err?.message || err}`); }
|
|
6713
8466
|
});
|
|
6714
8467
|
}
|
|
6715
8468
|
|
|
6716
8469
|
export { app, server, runs };
|
|
6717
8470
|
export const _testing = {
|
|
6718
|
-
wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen,
|
|
6719
|
-
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork,
|
|
8471
|
+
wireRun, wireScan, summarizeRuns, startScan, wireAgentGen, startAgentGen, wireScriptBench, startScriptBench,
|
|
8472
|
+
chatActions, chatRouter, channelHost, handleChatInbound, enqueueChatWork, answerRun,
|
|
6720
8473
|
chatNotifier, resumeRun, resolveHljsAssets, resolveEsmAsset, askJobs, askFollowers, askDeleting, resolveAskContext, flipCard,
|
|
6721
8474
|
emitDiffCommentsChanged, emitAskWorktrees, askWorktreesEnvelope, deleteAskThreadFully,
|
|
6722
8475
|
askTrackRun, liveRunEntry, liveDefragRun, memoryScopeKey, startRunHandler, emitMemoryChanged, askSystemPromptFor,
|
|
6723
8476
|
uiControl, bearerMatches,
|
|
8477
|
+
broadcast, askFilesRunDir,
|
|
6724
8478
|
};
|