conductor-remote 1.106.3 → 1.108.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -44
- package/dist/assets/{PierrePatch-CEuLSHL_.js → PierrePatch-DF-STc4J.js} +1 -1
- package/dist/assets/index-DM7eCDXg.css +1 -0
- package/dist/assets/index-DWbWwEOp.js +59 -0
- package/dist/index.html +2 -2
- package/dist/licenses/vscode-seti.txt +48 -0
- package/dist/sw.js +1 -1
- package/dist-node/src/logbuf.js +4 -3
- package/dist-node/src/mcp-tools.js +48 -138
- package/dist-node/src/model-cache.js +22 -7
- package/dist-node/src/orchestration-db.js +2150 -0
- package/dist-node/src/orchestration-schema.generated.js +306 -0
- package/dist-node/src/orchestration-schema.js +417 -0
- package/dist-node/src/preview-urls.js +1 -1
- package/dist-node/src/reads.js +135 -6
- package/dist-node/src/relay-processes.js +230 -0
- package/dist-node/src/roles.js +64 -12
- package/dist-node/src/routes.js +14 -1
- package/dist-node/src/search-worker.js +3 -1
- package/dist-node/src/server.js +1050 -211
- package/dist-node/src/shared.js +79 -2
- package/dist-node/src/transcript.js +20 -10
- package/dist-node/src/ui-lease-watchdog.js +94 -0
- package/dist-node/src/workflow-coordinator.js +2119 -0
- package/dist-node/src/workflow-effect-runner.js +336 -0
- package/dist-node/src/workflow-http.js +141 -0
- package/dist-node/src/workflow-machine.js +112 -0
- package/dist-node/src/workflow.js +111 -22
- package/dist-node/src/writes.js +157 -12
- package/package.json +9 -2
- package/dist/assets/index-BfTQtpd5.css +0 -1
- package/dist/assets/index-BiWQQKon.js +0 -58
package/dist-node/src/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import os from 'node:os';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import zlib from 'node:zlib';
|
|
7
7
|
import { applyAgentConfig } from "./agent-config.js";
|
|
8
|
-
import { attachmentPrompt, writeAttachment } from "./attachments.js";
|
|
8
|
+
import { ATTACHMENT_DIR, attachmentName, attachmentPrompt, attachmentToken, writeAttachment } from "./attachments.js";
|
|
9
9
|
import { startAutoUpdate, updateStatus } from "./autoupdate.js";
|
|
10
10
|
import { attachChangeStats } from "./change-stats.js";
|
|
11
11
|
import { isDefaultEffortLevel, readDefaultEfforts, writeDefaultEfforts } from "./conductor-settings.js";
|
|
@@ -24,22 +24,25 @@ import { mergePr } from "./merge.js";
|
|
|
24
24
|
import { ModelCache } from "./model-cache.js";
|
|
25
25
|
import { armNoSleep, disarmNoSleep, MAX_SECONDS as NOSLEEP_MAX_SECONDS, nosleepState, watchNoSleepExpiry } from "./nosleep.js";
|
|
26
26
|
import { chatRoute, noteViewing, notifyAll, notifyDevice, pushConfig, startNotifier, subscribeDevice, unsubscribeDevice } from "./notify.js";
|
|
27
|
+
import { IdempotencyConflictError, ORCHESTRATION_PROTOCOL_VERSION, OrchestrationDb, UiLeaseUnavailableError, WorkflowTransitionError } from "./orchestration-db.js";
|
|
27
28
|
import { ParkedPromptQueue } from "./parked.js";
|
|
28
29
|
import { PlanUsageService } from "./plan-usage.js";
|
|
29
30
|
import { attachPrStatus } from "./pr.js";
|
|
30
31
|
import { readPrefs, writePrefs } from "./prefs.js";
|
|
31
32
|
import { Reads } from "./reads.js";
|
|
32
|
-
import {
|
|
33
|
+
import { currentProcessStartIdentity, incompatibleRelayProcesses, listUiCapableRelayProcesses, processIdentityAlive } from "./relay-processes.js";
|
|
34
|
+
import { decodeRoles, RoleStore, roleModelIssues } from "./roles.js";
|
|
33
35
|
import { isRoute, routeParam, routes } from "./routes.js";
|
|
34
36
|
import { attachRunActivity } from "./run-activity.js";
|
|
35
37
|
import { foldHits, queryTokens, SearchIndex } from "./search.js";
|
|
36
38
|
import { SendOnce } from "./sendonce.js";
|
|
37
39
|
import { SessionPoller } from "./session-poller.js";
|
|
38
40
|
import { readSettings, writeSettings } from "./settings.js";
|
|
39
|
-
import { isOpenAIRealtimeVoice, isVoiceLanguage, OPENAI_REALTIME_VOICES, responseErrorMessage, VIEWING_HEADER, withoutWindowEvidence } from "./shared.js";
|
|
41
|
+
import { attachmentTokens, isOpenAIRealtimeVoice, isVoiceLanguage, modelLabel, OPENAI_REALTIME_VOICES, responseErrorMessage, scrubWorkflowSecrets, timestampMs, VIEWING_HEADER, withoutClientWindowEvidence, withoutWindowEvidence, workspaceTitle } from "./shared.js";
|
|
40
42
|
import { discardStagedAttachment, materializeStagedAttachments, pruneStagedAttachments, stageAttachment, stagedAttachments } from "./staged-attachments.js";
|
|
41
43
|
import { driftWarningLines, readExposeMode, tailscaleBin } from "./tailscale.js";
|
|
42
44
|
import { renderTranscript, transcriptMessage, transcriptThrough } from "./transcript.js";
|
|
45
|
+
import { recoverExpiredUiLease } from "./ui-lease-watchdog.js";
|
|
43
46
|
import { VoiceBriefBoard } from "./voice/brief.js";
|
|
44
47
|
import { VoiceBroker } from "./voice/broker.js";
|
|
45
48
|
import { openAIOriginForSipHost, readVoiceConfig, voicePort } from "./voice/config.js";
|
|
@@ -50,8 +53,10 @@ import { mintSipTicket, missingTicketConfig } from "./voice/ticket.js";
|
|
|
50
53
|
import { createVoiceTools } from "./voice/tools.js";
|
|
51
54
|
import { createWebRtcCall, MAX_SDP_CHARS } from "./voice/webrtc.js";
|
|
52
55
|
import { autoJoinHotspotMode, currentSsid, looksLikeHotspot, preferredNetworks } from "./wifi.js";
|
|
53
|
-
import {
|
|
54
|
-
import {
|
|
56
|
+
import { WorkflowCoordinator, WorkflowCoordinatorError } from "./workflow-coordinator.js";
|
|
57
|
+
import { parseConfirmUiStableRequest, parseStartWorkflowRequest, parseWorkflowAdoptRequest, parseWorkflowCompleteRequest, parseWorkflowDelegateRequest, parseWorkflowReplayRequest, parseWorkflowRetryRequest, WorkflowRequestError, workflowClientIsMcp } from "./workflow-http.js";
|
|
58
|
+
import { WorkflowGuardError } from "./workflow-machine.js";
|
|
59
|
+
import { archiveWorkspace, closeChat, configureSharedUiLeaseProvider, continueWorkspace, createWorkspace, describeActuator, EFFORT_LABELS, listAgentModels, lockBlocked, newChat, pickActuator, restartConductorApp, retryWontHelp, screenLocked, sendNeverStarted, setAgentOptions, setDefaultModel, setRestartGuard, setWorkspaceStatus, stopTurn, UiBusyError, uiBusy, uiQueueDepth, uiTurn, WORKSPACE_STATUS_LABELS, withGatedUiCommand, withUiPriority } from "./writes.js";
|
|
55
60
|
// Before anything that logs: from here on every console line is also kept in memory for
|
|
56
61
|
// `GET /api/logs`, so the phone can read why a send failed without ssh-ing into the Mac.
|
|
57
62
|
installLogCapture();
|
|
@@ -71,12 +76,52 @@ if (cfg.devWebPort !== undefined && process.env.CONDUCTOR_WORKSPACE_ID) {
|
|
|
71
76
|
devServers.advertisePreviewUrls(process.env.CONDUCTOR_WORKSPACE_ID, [{ name: 'Conductor Remote', url: preview.href }]);
|
|
72
77
|
}
|
|
73
78
|
const STAGED_ATTACHMENTS_DIR = path.join(stateDir(), 'attachment-staging');
|
|
79
|
+
/** Attachment IDs are already encoded in the immutable objective's Conductor tokens. */
|
|
80
|
+
function stagedAttachmentIdsInObjective(objective) {
|
|
81
|
+
return [
|
|
82
|
+
...new Set(attachmentTokens(objective).flatMap(token => {
|
|
83
|
+
const match = /^\.context\/attachments\/([A-Za-z0-9]{6})\//.exec(token.path);
|
|
84
|
+
return match ? [match[1]] : [];
|
|
85
|
+
}))
|
|
86
|
+
];
|
|
87
|
+
}
|
|
74
88
|
// Picker labels cannot be reconstructed from `sessions.model`, so they belong to
|
|
75
89
|
// relay state alongside the prompt queues. This lets a brand-new workspace choose
|
|
76
90
|
// from a list before Conductor has created its first chat.
|
|
77
91
|
const modelCache = new ModelCache(path.join(stateDir(), 'model-cache.json'));
|
|
78
92
|
const planUsage = new PlanUsageService();
|
|
79
93
|
const roleStore = new RoleStore(path.join(stateDir(), 'roles.json'));
|
|
94
|
+
const orchestration = new OrchestrationDb(path.join(stateDir(), 'orchestration.db'), {
|
|
95
|
+
processProbe: processIdentityAlive
|
|
96
|
+
});
|
|
97
|
+
const relayIdentity = {
|
|
98
|
+
instanceId: crypto.randomUUID(),
|
|
99
|
+
pid: process.pid,
|
|
100
|
+
processStartedAt: currentProcessStartIdentity(),
|
|
101
|
+
protocolVersion: ORCHESTRATION_PROTOCOL_VERSION
|
|
102
|
+
};
|
|
103
|
+
function orchestrationUnavailableReason() {
|
|
104
|
+
return orchestration.schemaWarning ?? `orchestration schema ${orchestration.schemaVersion} is unsupported`;
|
|
105
|
+
}
|
|
106
|
+
if (orchestration.writable) {
|
|
107
|
+
orchestration.registerRelayInstance({ ...relayIdentity, canDriveUi: true });
|
|
108
|
+
const sharedUiLeases = orchestration.createSharedUiLeaseProvider(relayIdentity, { leaseMs: 2 * 60_000 });
|
|
109
|
+
configureSharedUiLeaseProvider({
|
|
110
|
+
acquire: async (request) => {
|
|
111
|
+
try {
|
|
112
|
+
return await sharedUiLeases.acquire(request);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error instanceof UiLeaseUnavailableError)
|
|
116
|
+
throw new UiBusyError(0);
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
else {
|
|
123
|
+
console.warn(`[relay] ${orchestrationUnavailableReason()}; Workflow is disabled until this relay is upgraded`);
|
|
124
|
+
}
|
|
80
125
|
/** One store object per live worktree, so the queue never registers a path twice. */
|
|
81
126
|
const delegationStores = new Map();
|
|
82
127
|
function delegationStore(ws) {
|
|
@@ -95,7 +140,7 @@ function liveDelegationStores() {
|
|
|
95
140
|
return store ? [store] : [];
|
|
96
141
|
});
|
|
97
142
|
}
|
|
98
|
-
/**
|
|
143
|
+
/** Preserve role identity for already-persisted pre-coordinator Workflow prompts. */
|
|
99
144
|
function assignWorkflowRoot(ws, sessionId, role, assignedAt) {
|
|
100
145
|
const store = delegationStore(ws);
|
|
101
146
|
if (!store)
|
|
@@ -106,12 +151,6 @@ function assignWorkflowRoot(ws, sessionId, role, assignedAt) {
|
|
|
106
151
|
const session = reads.getSession(sessionId);
|
|
107
152
|
if (!session)
|
|
108
153
|
return { ok: false, error: 'the workflow root chat is unavailable' };
|
|
109
|
-
if (session.permission_mode === 'plan') {
|
|
110
|
-
return {
|
|
111
|
-
ok: false,
|
|
112
|
-
error: 'the workflow root inherited Plan mode; switch it to ordinary chat mode and retry'
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
154
|
const assignments = store.sessionRoles();
|
|
116
155
|
if (assignments.warning)
|
|
117
156
|
return { ok: false, error: `cannot assign the workflow root: ${assignments.warning}` };
|
|
@@ -244,6 +283,51 @@ const voiceServer = createVoiceServer({
|
|
|
244
283
|
// the phone opening the app and the send landing.
|
|
245
284
|
setRestartGuard(() => !reads.listWorkspaces().some(w => w.session_status === 'working'));
|
|
246
285
|
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
286
|
+
let uiLeaseRecoveryRunning = false;
|
|
287
|
+
/**
|
|
288
|
+
* A dead relay can leave its detached, already-authorized GUI helper behind. Keep
|
|
289
|
+
* the old mutex until that exact process group is gone, allow its external event
|
|
290
|
+
* to settle, then let the database CAS create the persistent safety hold.
|
|
291
|
+
*/
|
|
292
|
+
async function recoverUiLease() {
|
|
293
|
+
if (!orchestration.writable || uiLeaseRecoveryRunning)
|
|
294
|
+
return;
|
|
295
|
+
uiLeaseRecoveryRunning = true;
|
|
296
|
+
try {
|
|
297
|
+
const result = await recoverExpiredUiLease(orchestration, relayIdentity);
|
|
298
|
+
if (result.status === 'external_process_alive') {
|
|
299
|
+
console.warn(`[workflow] expired UI helper ${result.owner.externalProcess?.pid ?? 'unknown'} could not be proven dead; retaining its mutex`);
|
|
300
|
+
}
|
|
301
|
+
else if (result.status === 'owned_external_terminated') {
|
|
302
|
+
console.warn(`[workflow] terminated overdue UI helper ${result.owner.externalProcess?.pid ?? 'unknown'}`);
|
|
303
|
+
}
|
|
304
|
+
else if (result.status === 'reclaimed') {
|
|
305
|
+
console.warn(`[workflow] reclaimed expired UI action ${result.owner.actionId}${result.quarantined ? ' into persistent quarantine' : ''}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
console.error('[workflow] expired UI lease recovery failed:', error);
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
uiLeaseRecoveryRunning = false;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/** Fail closed if another UI-capable relay cannot prove it speaks this protocol. */
|
|
316
|
+
async function workflowCompatibilityError() {
|
|
317
|
+
if (!orchestration.writable) {
|
|
318
|
+
return `Workflow is disabled because ${orchestrationUnavailableReason()}.`;
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
const processes = await listUiCapableRelayProcesses();
|
|
322
|
+
const incompatible = incompatibleRelayProcesses(processes, orchestration.listRelayInstances(), ORCHESTRATION_PROTOCOL_VERSION);
|
|
323
|
+
if (!incompatible.length)
|
|
324
|
+
return null;
|
|
325
|
+
return `Workflow needs every live conductor-remote UI relay on protocol ${ORCHESTRATION_PROTOCOL_VERSION}. Stop incompatible PID${incompatible.length === 1 ? '' : 's'} ${incompatible.map(process => process.pid).join(', ')} and try again.`;
|
|
326
|
+
}
|
|
327
|
+
catch (error) {
|
|
328
|
+
return `Workflow could not verify the live relay processes: ${error instanceof Error ? error.message : String(error)}`;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
247
331
|
/**
|
|
248
332
|
* A deep link has no request id to correlate with the workspace row it creates. Keep
|
|
249
333
|
* relay-originated creations single-flight until that row appears, or two simultaneous
|
|
@@ -251,7 +335,7 @@ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
|
|
|
251
335
|
* happen in the gap, so callers also narrow the fresh row to the requested repo.
|
|
252
336
|
*/
|
|
253
337
|
let workspaceCreationTail = Promise.resolve();
|
|
254
|
-
async function createWorkspaceAndRead(prompt, repoPath, repoName) {
|
|
338
|
+
async function createWorkspaceAndRead(prompt, repoPath, repoName, strictUnique = false) {
|
|
255
339
|
const previous = workspaceCreationTail;
|
|
256
340
|
let release = () => { };
|
|
257
341
|
workspaceCreationTail = new Promise(resolve => {
|
|
@@ -267,11 +351,13 @@ async function createWorkspaceAndRead(prompt, repoPath, repoName) {
|
|
|
267
351
|
// Creating a worktree takes a beat longer than opening a chat does.
|
|
268
352
|
for (let attempt = 0; attempt < 40; attempt++) {
|
|
269
353
|
await sleep(500);
|
|
270
|
-
const
|
|
354
|
+
const candidates = reads
|
|
271
355
|
.listWorkspaces()
|
|
272
|
-
.
|
|
273
|
-
if (
|
|
274
|
-
return { result
|
|
356
|
+
.filter(workspace => !before.has(workspace.id) && (!repoName || workspace.repo_name === repoName));
|
|
357
|
+
if (strictUnique && candidates.length > 1)
|
|
358
|
+
return { result };
|
|
359
|
+
if (candidates[0])
|
|
360
|
+
return { result, created: candidates[0] };
|
|
275
361
|
}
|
|
276
362
|
return { result };
|
|
277
363
|
}
|
|
@@ -577,6 +663,15 @@ function referencedStagedAttachments() {
|
|
|
577
663
|
for (const stageId of prompt.attachmentIds ?? [])
|
|
578
664
|
referenced.add(stageId);
|
|
579
665
|
}
|
|
666
|
+
if (orchestration.writable) {
|
|
667
|
+
for (const projection of orchestration.listWorkflowProjections()) {
|
|
668
|
+
const run = orchestration.getWorkflowRun(projection.id);
|
|
669
|
+
if (run?.target.kind !== 'new_workspace')
|
|
670
|
+
continue;
|
|
671
|
+
for (const stageId of stagedAttachmentIdsInObjective(run.objective))
|
|
672
|
+
referenced.add(stageId);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
580
675
|
return referenced;
|
|
581
676
|
}
|
|
582
677
|
function sweepStagedAttachments() {
|
|
@@ -693,11 +788,6 @@ async function configureDelegation(job) {
|
|
|
693
788
|
const before = reads.getSession(job.childSessionId);
|
|
694
789
|
if (!before)
|
|
695
790
|
return delegationError('session_not_found', 'the delegated child chat is gone', false);
|
|
696
|
-
// Never touch the buggy Plan control for orchestration. A child that somehow
|
|
697
|
-
// inherited Plan is refused before its task is sent instead of relying on it.
|
|
698
|
-
if (before.permission_mode === 'plan') {
|
|
699
|
-
return delegationError('configuration_failed', 'the child inherited Plan mode; delegated roles require default mode', false);
|
|
700
|
-
}
|
|
701
791
|
const applied = await withUiPriority('background', () => applyAgentPatch(ws, job.childSessionId, {
|
|
702
792
|
model: job.resolvedRole.model,
|
|
703
793
|
effort: job.resolvedRole.effort,
|
|
@@ -708,9 +798,6 @@ async function configureDelegation(job) {
|
|
|
708
798
|
const after = reads.getSession(job.childSessionId);
|
|
709
799
|
if (!after)
|
|
710
800
|
return delegationError('session_not_found', 'the configured child chat disappeared', false);
|
|
711
|
-
if (after.permission_mode === 'plan') {
|
|
712
|
-
return delegationError('configuration_failed', 'the child entered Plan mode; delegated roles require default mode', false);
|
|
713
|
-
}
|
|
714
801
|
if (after.agent_type !== job.resolvedRole.agentType) {
|
|
715
802
|
return delegationError('configuration_failed', `Conductor recorded provider ${after.agent_type ?? 'unknown'}, not ${job.resolvedRole.agentType}`);
|
|
716
803
|
}
|
|
@@ -923,95 +1010,168 @@ function attachDelegationState(workspaces) {
|
|
|
923
1010
|
Object.assign(ws, { delegation_warning: warnings.join('; ') });
|
|
924
1011
|
}
|
|
925
1012
|
}
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
1013
|
+
/** Keep process identity and raw recovery evidence private; the phone only needs the hold and its cause. */
|
|
1014
|
+
function wireUiQuarantine() {
|
|
1015
|
+
if (!orchestration.writable)
|
|
1016
|
+
return undefined;
|
|
1017
|
+
const quarantine = orchestration.getUiQuarantine();
|
|
1018
|
+
if (!quarantine.active)
|
|
1019
|
+
return undefined;
|
|
1020
|
+
const bounded = (value, maximum) => withoutWindowEvidence(scrubWorkflowSecrets(value)).slice(0, maximum);
|
|
1021
|
+
return {
|
|
1022
|
+
active: true,
|
|
1023
|
+
reason: bounded(quarantine.reason ??
|
|
1024
|
+
'A previous automated Conductor UI action may have completed without a confirmed receipt. Inspect Conductor before continuing.', 500),
|
|
1025
|
+
createdAt: quarantine.createdAt ?? 0,
|
|
1026
|
+
...(quarantine.actionId ? { actionId: bounded(quarantine.actionId, 256) } : {}),
|
|
1027
|
+
...(quarantine.effectId ? { effectId: bounded(quarantine.effectId, 256) } : {})
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
function workflowJobStatus(state) {
|
|
1031
|
+
if (state === 'owned')
|
|
1032
|
+
return 'opening';
|
|
1033
|
+
if (state === 'dormant')
|
|
1034
|
+
return 'queued';
|
|
1035
|
+
if (state === 'cancelled')
|
|
1036
|
+
return 'failed';
|
|
1037
|
+
return state;
|
|
1038
|
+
}
|
|
1039
|
+
function projectWorkflowDelegation(workflow, job) {
|
|
1040
|
+
if (!workflow.workspaceId || job.state === 'cancelled')
|
|
1041
|
+
return null;
|
|
1042
|
+
return {
|
|
1043
|
+
id: job.id,
|
|
1044
|
+
workflowId: workflow.id,
|
|
1045
|
+
logicalKey: job.logicalKey,
|
|
1046
|
+
bootstrap: job.logicalKey === 'explore:0',
|
|
1047
|
+
workspaceId: workflow.workspaceId,
|
|
1048
|
+
parentSessionId: workflow.rootSessionId ?? '',
|
|
1049
|
+
...(job.childSessionId ? { childSessionId: job.childSessionId } : {}),
|
|
1050
|
+
role: job.role,
|
|
1051
|
+
// The immutable preamble remains coordinator-private. Only the public picker
|
|
1052
|
+
// settings are useful for role chips and list_delegations.
|
|
1053
|
+
resolvedRole: {
|
|
1054
|
+
agentType: job.resolvedRole.agentType,
|
|
1055
|
+
model: job.resolvedRole.model,
|
|
1056
|
+
...(job.resolvedRole.effort ? { effort: job.resolvedRole.effort } : {}),
|
|
1057
|
+
...(job.resolvedRole.fast === undefined ? {} : { fast: job.resolvedRole.fast })
|
|
1058
|
+
},
|
|
1059
|
+
prompt: scrubWorkflowSecrets(job.prompt).slice(0, 500),
|
|
1060
|
+
returnMode: 'queue',
|
|
1061
|
+
status: workflowJobStatus(job.state),
|
|
1062
|
+
attempts: job.attemptCount,
|
|
1063
|
+
createdAt: job.createdAt,
|
|
1064
|
+
updatedAt: job.updatedAt,
|
|
1065
|
+
...(job.state === 'failed'
|
|
1066
|
+
? {
|
|
1067
|
+
failure: {
|
|
1068
|
+
code: 'workflow_blocked',
|
|
1069
|
+
message: workflow.error?.message ?? 'Workflow job failed.',
|
|
1070
|
+
retryable: workflow.actions.canRetry
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
: {})
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
function attachWorkflowState(workspaces) {
|
|
1077
|
+
if (!orchestration.writable)
|
|
1078
|
+
return [];
|
|
1079
|
+
const projections = orchestration.listWorkflowProjections();
|
|
1080
|
+
const byWorkspace = new Map(workspaces.map(workspace => [workspace.id, workspace]));
|
|
1081
|
+
for (const workflow of projections) {
|
|
1082
|
+
if (!workflow.workspaceId)
|
|
1083
|
+
continue;
|
|
1084
|
+
const workspace = byWorkspace.get(workflow.workspaceId);
|
|
1085
|
+
if (!workspace)
|
|
1086
|
+
continue;
|
|
1087
|
+
const run = orchestration.getWorkflowRun(workflow.id);
|
|
1088
|
+
if (!run)
|
|
1089
|
+
continue;
|
|
1090
|
+
const jobs = orchestration
|
|
1091
|
+
.listWorkflowJobs(workflow.id)
|
|
1092
|
+
.flatMap(job => projectWorkflowDelegation(workflow, job) ?? []);
|
|
1093
|
+
workspace.delegations = [...(workspace.delegations ?? []), ...jobs];
|
|
1094
|
+
workspace.session_roles = { ...(workspace.session_roles ?? {}) };
|
|
1095
|
+
if (workflow.rootSessionId) {
|
|
1096
|
+
workspace.session_roles[workflow.rootSessionId] = {
|
|
1097
|
+
role: 'planning',
|
|
1098
|
+
workflowId: workflow.id,
|
|
1099
|
+
assignedAt: workflow.createdAt
|
|
1100
|
+
};
|
|
1101
|
+
}
|
|
1102
|
+
for (const job of orchestration.listWorkflowJobs(workflow.id)) {
|
|
1103
|
+
if (!job.childSessionId)
|
|
1104
|
+
continue;
|
|
1105
|
+
workspace.session_roles[job.childSessionId] = {
|
|
1106
|
+
role: job.role,
|
|
1107
|
+
delegationId: job.id,
|
|
1108
|
+
workflowId: workflow.id,
|
|
1109
|
+
assignedAt: job.createdAt
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
// Compatibility for cached clients that only understand one workspace-level run.
|
|
1113
|
+
if (!workspace.workflow || workflow.rootSessionId === workspace.active_session_id)
|
|
1114
|
+
workspace.workflow = workflow;
|
|
932
1115
|
}
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1116
|
+
return projections;
|
|
1117
|
+
}
|
|
1118
|
+
function workflowOwningSession(sessionId) {
|
|
1119
|
+
if (!orchestration.writable)
|
|
1120
|
+
return null;
|
|
1121
|
+
for (const projection of orchestration.listWorkflowProjections()) {
|
|
1122
|
+
if (projection.rootSessionId === sessionId)
|
|
1123
|
+
return projection;
|
|
1124
|
+
if (orchestration.listWorkflowJobs(projection.id).some(job => job.childSessionId === sessionId))
|
|
1125
|
+
return projection;
|
|
1126
|
+
}
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
function workflowFrozenError(sessionId) {
|
|
1130
|
+
const workflow = workflowOwningSession(sessionId);
|
|
1131
|
+
if (!workflow)
|
|
1132
|
+
return null;
|
|
1133
|
+
return {
|
|
1134
|
+
error: {
|
|
1135
|
+
code: 'workflow_role_frozen',
|
|
1136
|
+
message: `Workflow ${workflow.id} froze this chat's model, effort, and Fast setting at Start.`,
|
|
1137
|
+
retryable: false
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
938
1140
|
}
|
|
939
1141
|
function intakeError(code, message, retryable = false) {
|
|
940
1142
|
return { ok: false, error: { code, message, retryable } };
|
|
941
1143
|
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
|
|
946
|
-
if (!roleName || !prompt)
|
|
947
|
-
return intakeError('invalid_request', 'role and prompt are required');
|
|
948
|
-
if (body.returnMode !== undefined && body.returnMode !== 'queue' && body.returnMode !== 'steer') {
|
|
949
|
-
return intakeError('invalid_request', 'returnMode must be queue or steer');
|
|
1144
|
+
function workflowHttpError(error) {
|
|
1145
|
+
if (error instanceof WorkflowRequestError || error instanceof WorkflowGuardError) {
|
|
1146
|
+
return { status: error.status, error: { code: error.code, message: error.message, retryable: false } };
|
|
950
1147
|
}
|
|
951
|
-
if (
|
|
952
|
-
return
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1148
|
+
if (error instanceof WorkflowCoordinatorError) {
|
|
1149
|
+
return {
|
|
1150
|
+
status: error.status,
|
|
1151
|
+
error: { code: error.code, message: error.message, retryable: error.retryable }
|
|
1152
|
+
};
|
|
956
1153
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
1154
|
+
if (error instanceof IdempotencyConflictError) {
|
|
1155
|
+
return {
|
|
1156
|
+
status: 409,
|
|
1157
|
+
error: { code: 'idempotency_conflict', message: error.message, retryable: false }
|
|
1158
|
+
};
|
|
962
1159
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
const parent = reads.getSession(parentSessionId);
|
|
969
|
-
if (!parent)
|
|
970
|
-
return intakeError('session_not_found', 'parent chat not found in that workspace');
|
|
971
|
-
if (!parent.agent_type)
|
|
972
|
-
return intakeError('provider_unknown', 'the parent chat provider is unknown');
|
|
973
|
-
const storedRoles = roleStore.read();
|
|
974
|
-
if (storedRoles.warning)
|
|
975
|
-
return intakeError('state_invalid', storedRoles.warning);
|
|
976
|
-
const resolved = resolveRole(storedRoles.config, roleName, modelCache.list());
|
|
977
|
-
if (!resolved.ok)
|
|
978
|
-
return { ok: false, error: resolved.error };
|
|
979
|
-
if (resolved.role.agentType === parent.agent_type) {
|
|
980
|
-
return intakeError('same_provider', `Role ${roleName} uses the parent's ${parent.agent_type} provider.`);
|
|
981
|
-
}
|
|
982
|
-
if (body.throughRowid !== undefined) {
|
|
983
|
-
const { entries } = reads.getMessages(parentSessionId);
|
|
984
|
-
if (!transcriptThrough(entries, body.throughRowid)) {
|
|
985
|
-
return intakeError('invalid_request', 'throughRowid is not in the parent chat');
|
|
986
|
-
}
|
|
1160
|
+
if (error instanceof WorkflowTransitionError) {
|
|
1161
|
+
return {
|
|
1162
|
+
status: 409,
|
|
1163
|
+
error: { code: 'workflow_phase_invalid', message: error.message, retryable: false }
|
|
1164
|
+
};
|
|
987
1165
|
}
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
id: crypto.randomUUID(),
|
|
992
|
-
workspaceId: ws.id,
|
|
993
|
-
parentSessionId,
|
|
994
|
-
role: roleName,
|
|
995
|
-
resolvedRole: resolved.role,
|
|
996
|
-
prompt,
|
|
997
|
-
returnMode: body.returnMode ?? 'queue',
|
|
998
|
-
includeThinking: body.includeThinking !== false,
|
|
999
|
-
...(body.throughRowid === undefined ? {} : { throughRowid: body.throughRowid }),
|
|
1000
|
-
status: 'queued',
|
|
1001
|
-
attempts: 0,
|
|
1002
|
-
createdAt: now,
|
|
1003
|
-
updatedAt: now
|
|
1004
|
-
};
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
async function workflowRequestBody(req) {
|
|
1005
1169
|
try {
|
|
1006
|
-
|
|
1007
|
-
if (!store)
|
|
1008
|
-
return intakeError('worktree_unavailable', 'worktree path unresolved');
|
|
1009
|
-
delegationQueue.enqueue(store, job);
|
|
1170
|
+
return JSON.parse((await readBody(req)) || '{}');
|
|
1010
1171
|
}
|
|
1011
|
-
catch
|
|
1012
|
-
|
|
1172
|
+
catch {
|
|
1173
|
+
throw new WorkflowRequestError('Workflow request body must be valid JSON.');
|
|
1013
1174
|
}
|
|
1014
|
-
return { ok: true, delegationId: job.id, role: roleName, model: resolved.role.model };
|
|
1015
1175
|
}
|
|
1016
1176
|
/**
|
|
1017
1177
|
* One prompt per tap, however many requests carry it (src/sendonce.ts). Keyed on the
|
|
@@ -1064,6 +1224,549 @@ const parkedPrompts = new ParkedPromptQueue(path.join(stateDir(), 'parked-prompt
|
|
|
1064
1224
|
});
|
|
1065
1225
|
}
|
|
1066
1226
|
});
|
|
1227
|
+
function workflowDeliveryCursor(sessionId) {
|
|
1228
|
+
const cursor = reads.deliveryCursor(sessionId);
|
|
1229
|
+
return { rowid: cursor.rowid, outboxIds: [...cursor.outboxIds].sort() };
|
|
1230
|
+
}
|
|
1231
|
+
function readsDeliveryCursor(value) {
|
|
1232
|
+
if (!value || typeof value !== 'object')
|
|
1233
|
+
return null;
|
|
1234
|
+
const cursor = value;
|
|
1235
|
+
if (!Number.isSafeInteger(cursor.rowid) || !Array.isArray(cursor.outboxIds))
|
|
1236
|
+
return null;
|
|
1237
|
+
if (cursor.outboxIds.some(id => typeof id !== 'string'))
|
|
1238
|
+
return null;
|
|
1239
|
+
return { rowid: cursor.rowid, outboxIds: new Set(cursor.outboxIds) };
|
|
1240
|
+
}
|
|
1241
|
+
function workspaceBaseline(value) {
|
|
1242
|
+
if (!value || typeof value !== 'object')
|
|
1243
|
+
return null;
|
|
1244
|
+
const baseline = value;
|
|
1245
|
+
return baseline.kind === 'workspace_ids' &&
|
|
1246
|
+
typeof baseline.repo === 'string' &&
|
|
1247
|
+
Array.isArray(baseline.workspaceIds) &&
|
|
1248
|
+
baseline.workspaceIds.every(id => typeof id === 'string')
|
|
1249
|
+
? baseline
|
|
1250
|
+
: null;
|
|
1251
|
+
}
|
|
1252
|
+
function sessionBaseline(value) {
|
|
1253
|
+
if (!value || typeof value !== 'object')
|
|
1254
|
+
return null;
|
|
1255
|
+
const baseline = value;
|
|
1256
|
+
return baseline.kind === 'session_ids' &&
|
|
1257
|
+
typeof baseline.workspaceId === 'string' &&
|
|
1258
|
+
Array.isArray(baseline.sessionIds) &&
|
|
1259
|
+
baseline.sessionIds.every(id => typeof id === 'string')
|
|
1260
|
+
? baseline
|
|
1261
|
+
: null;
|
|
1262
|
+
}
|
|
1263
|
+
function workflowRootInspection(ws, session) {
|
|
1264
|
+
const cursor = workflowDeliveryCursor(session.id);
|
|
1265
|
+
const userRows = reads.getMessages(session.id).entries.filter(entry => entry.role === 'user');
|
|
1266
|
+
const firstPrompt = firstPrompts.list().some(entry => entry.workspaceId === ws.id);
|
|
1267
|
+
const parked = parkedPrompts.list().some(entry => entry.sessionId === session.id);
|
|
1268
|
+
const reasons = [
|
|
1269
|
+
session.status !== 'idle' ? `the chat status is ${session.status ?? 'unknown'}, not idle` : '',
|
|
1270
|
+
session.background_tasks.length ? 'the chat is waiting on a background task' : '',
|
|
1271
|
+
session.last_user_message_at ? 'the chat already has a user message timestamp' : '',
|
|
1272
|
+
userRows.length ? 'the chat already has a user message' : '',
|
|
1273
|
+
cursor.outboxIds.length ? 'the chat already has a queued prompt' : '',
|
|
1274
|
+
firstPrompt ? 'the workspace already has a pending first prompt' : '',
|
|
1275
|
+
parked ? 'the chat already has a parked prompt' : ''
|
|
1276
|
+
].filter(Boolean);
|
|
1277
|
+
return {
|
|
1278
|
+
workspaceId: ws.id,
|
|
1279
|
+
rootSessionId: session.id,
|
|
1280
|
+
pristine: reasons.length === 0,
|
|
1281
|
+
pristineEvidence: {
|
|
1282
|
+
status: session.status,
|
|
1283
|
+
backgroundTasks: session.background_tasks.length,
|
|
1284
|
+
lastUserMessageAt: session.last_user_message_at,
|
|
1285
|
+
userRows: userRows.length,
|
|
1286
|
+
outboxRows: cursor.outboxIds.length,
|
|
1287
|
+
firstPrompt,
|
|
1288
|
+
parked
|
|
1289
|
+
},
|
|
1290
|
+
deliveryCursor: cursor,
|
|
1291
|
+
...(reasons.length ? { reason: `Workflow requires a pristine root: ${reasons.join('; ')}.` } : {})
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function assertWorkflowRootStillPristine(run, expectedSessionId) {
|
|
1295
|
+
const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
|
|
1296
|
+
const session = reads.getSession(expectedSessionId);
|
|
1297
|
+
if (!ws ||
|
|
1298
|
+
!run.rootSessionId ||
|
|
1299
|
+
run.rootSessionId !== expectedSessionId ||
|
|
1300
|
+
!session ||
|
|
1301
|
+
reads.sessionWorkspaceId(expectedSessionId) !== ws.id) {
|
|
1302
|
+
throw new WorkflowCoordinatorError('workflow_root_not_pristine', 'The exact Workflow root binding changed before its first prompt could be dispatched.');
|
|
1303
|
+
}
|
|
1304
|
+
const inspection = workflowRootInspection(ws, session);
|
|
1305
|
+
if (!inspection.pristine) {
|
|
1306
|
+
throw new WorkflowCoordinatorError('workflow_root_not_pristine', inspection.reason ?? 'Workflow requires a pristine root chat.');
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
function sessionMatchesWorkflowRole(session, role) {
|
|
1310
|
+
const selected = modelLabel(session.model, [role.model]).toLowerCase();
|
|
1311
|
+
const wanted = role.model.toLowerCase();
|
|
1312
|
+
return (session.agent_type === role.agentType &&
|
|
1313
|
+
(selected === wanted || selected.startsWith(wanted)) &&
|
|
1314
|
+
(role.effort === undefined || session.claude_effort_level === role.effort) &&
|
|
1315
|
+
(role.fast === undefined || Boolean(session.fast_mode) === role.fast));
|
|
1316
|
+
}
|
|
1317
|
+
function stableWorkflowAttachment(worktree, jobId, name, body) {
|
|
1318
|
+
// Conductor requires six alphanumerics. Preserve all six characters' entropy
|
|
1319
|
+
// instead of truncating a hex digest to only 24 bits for a long-lived stable path.
|
|
1320
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
1321
|
+
const digest = crypto.createHash('sha256').update(`workflow-handoff:${jobId}`).digest();
|
|
1322
|
+
const id = Array.from(digest.subarray(0, 6), byte => alphabet[byte % alphabet.length]).join('');
|
|
1323
|
+
const safeName = attachmentName(name);
|
|
1324
|
+
const directory = path.join(worktree, ATTACHMENT_DIR, id);
|
|
1325
|
+
const destination = path.join(directory, safeName);
|
|
1326
|
+
fs.mkdirSync(directory, { recursive: true });
|
|
1327
|
+
try {
|
|
1328
|
+
fs.writeFileSync(destination, body, { flag: 'wx' });
|
|
1329
|
+
}
|
|
1330
|
+
catch (error) {
|
|
1331
|
+
if (error.code !== 'EEXIST')
|
|
1332
|
+
throw error;
|
|
1333
|
+
if (fs.readFileSync(destination, 'utf8') !== body) {
|
|
1334
|
+
throw new Error(`the stable Workflow handoff path ${path.join(ATTACHMENT_DIR, id, safeName)} is occupied`);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
return attachmentToken(safeName, path.join(ATTACHMENT_DIR, id, safeName));
|
|
1338
|
+
}
|
|
1339
|
+
function workflowWorkspaceCandidate(ws) {
|
|
1340
|
+
return {
|
|
1341
|
+
id: ws.id,
|
|
1342
|
+
title: workspaceTitle(ws),
|
|
1343
|
+
repo: ws.repo_name ?? '',
|
|
1344
|
+
createdAt: timestampMs(ws.created_at),
|
|
1345
|
+
kind: 'workspace'
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
function workflowSessionCandidate(ws, session) {
|
|
1349
|
+
return {
|
|
1350
|
+
id: session.id,
|
|
1351
|
+
title: session.title?.trim() || '(untitled chat)',
|
|
1352
|
+
repo: ws.repo_name ?? '',
|
|
1353
|
+
createdAt: timestampMs(session.created_at),
|
|
1354
|
+
kind: 'session'
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
function workflowSessionId(effect) {
|
|
1358
|
+
if (!effect.target || typeof effect.target !== 'object')
|
|
1359
|
+
return null;
|
|
1360
|
+
const id = effect.target.sessionId;
|
|
1361
|
+
return typeof id === 'string' ? id : null;
|
|
1362
|
+
}
|
|
1363
|
+
function workflowEffectPrompt(effect) {
|
|
1364
|
+
if (!effect.inputs || typeof effect.inputs !== 'object')
|
|
1365
|
+
return null;
|
|
1366
|
+
const prompt = effect.inputs.prompt;
|
|
1367
|
+
return typeof prompt === 'string' ? prompt : null;
|
|
1368
|
+
}
|
|
1369
|
+
function workflowEffectMarker(effect) {
|
|
1370
|
+
if (!effect.inputs || typeof effect.inputs !== 'object')
|
|
1371
|
+
return null;
|
|
1372
|
+
const marker = effect.inputs.correlationMarker;
|
|
1373
|
+
return typeof marker === 'string' ? marker : null;
|
|
1374
|
+
}
|
|
1375
|
+
function withWorkflowEffectGate(call, operation) {
|
|
1376
|
+
return call.dispatch.mode === 'gated_child'
|
|
1377
|
+
? withGatedUiCommand(call.dispatch.gatedProcessReady, () => uiTurn(operation))
|
|
1378
|
+
: uiTurn(operation);
|
|
1379
|
+
}
|
|
1380
|
+
async function sendWorkflowPrompt(call, queue) {
|
|
1381
|
+
const ws = call.run.workspaceId ? reads.getWorkspace(call.run.workspaceId) : null;
|
|
1382
|
+
if (!ws || reads.sessionWorkspaceId(call.sessionId) !== ws.id) {
|
|
1383
|
+
throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow destination chat is unavailable.', {
|
|
1384
|
+
status: 404
|
|
1385
|
+
});
|
|
1386
|
+
}
|
|
1387
|
+
const before = readsDeliveryCursor(call.effect.cursor) ?? reads.deliveryCursor(call.sessionId);
|
|
1388
|
+
const result = await withWorkflowEffectGate(call, async () => {
|
|
1389
|
+
if (actuator.name !== 'sidecar')
|
|
1390
|
+
return deliverPrompt(ws, call.sessionId, call.text, SEND_BUDGET_MS, queue);
|
|
1391
|
+
const located = locateChat(ws, call.sessionId);
|
|
1392
|
+
if ('error' in located)
|
|
1393
|
+
return { ok: false, strategy: actuator.name, attempts: 0, error: located.error };
|
|
1394
|
+
const sent = await actuator.send({ workspace: ws, sessionId: call.sessionId, tab: located.tab }, call.text, {
|
|
1395
|
+
queue
|
|
1396
|
+
});
|
|
1397
|
+
if (!sent.ok)
|
|
1398
|
+
return { ...sent, attempts: 1 };
|
|
1399
|
+
const landed = await confirmDelivery(call.sessionId, call.text, before, Date.now() + CONFIRM_WINDOW_MS);
|
|
1400
|
+
return landed
|
|
1401
|
+
? { ...sent, attempts: 1 }
|
|
1402
|
+
: {
|
|
1403
|
+
ok: false,
|
|
1404
|
+
strategy: sent.strategy,
|
|
1405
|
+
attempts: 1,
|
|
1406
|
+
error: 'Conductor did not record the sidecar Workflow prompt; automatic replay is disabled.'
|
|
1407
|
+
};
|
|
1408
|
+
});
|
|
1409
|
+
const receipt = reads.deliveryReceiptSince(call.sessionId, call.text, before);
|
|
1410
|
+
if (receipt)
|
|
1411
|
+
return receipt;
|
|
1412
|
+
throw new WorkflowCoordinatorError('workflow_effect_failed', result.error ?? 'Conductor did not record an accepted Workflow prompt.', { retryable: !retryWontHelp(result.error) });
|
|
1413
|
+
}
|
|
1414
|
+
const workflowCoordinator = orchestration.writable
|
|
1415
|
+
? new WorkflowCoordinator(orchestration, relayIdentity, {
|
|
1416
|
+
captureWorkspaceBaseline: async (repoName) => {
|
|
1417
|
+
const repo = reads.listRepos().find(candidate => candidate.name === repoName);
|
|
1418
|
+
if (!repo) {
|
|
1419
|
+
throw new WorkflowCoordinatorError('workflow_not_found', `Unknown repo ${repoName}.`, { status: 404 });
|
|
1420
|
+
}
|
|
1421
|
+
if (!repo.root_path) {
|
|
1422
|
+
throw new WorkflowCoordinatorError('invalid_request', `${repo.name} has no checkout path.`);
|
|
1423
|
+
}
|
|
1424
|
+
return {
|
|
1425
|
+
kind: 'workspace_ids',
|
|
1426
|
+
repo: repo.name,
|
|
1427
|
+
workspaceIds: reads
|
|
1428
|
+
.listWorkspaces()
|
|
1429
|
+
.filter(workspace => workspace.repo_name === repo.name)
|
|
1430
|
+
.map(workspace => workspace.id)
|
|
1431
|
+
.sort()
|
|
1432
|
+
};
|
|
1433
|
+
},
|
|
1434
|
+
inspectExistingRoot: async (target) => {
|
|
1435
|
+
const ws = reads.getWorkspace(target.workspaceId);
|
|
1436
|
+
if (!ws || reads.sessionWorkspaceId(target.sessionId) !== ws.id)
|
|
1437
|
+
return null;
|
|
1438
|
+
const session = reads.getSession(target.sessionId);
|
|
1439
|
+
return session ? workflowRootInspection(ws, session) : null;
|
|
1440
|
+
},
|
|
1441
|
+
bindCreatedRoot: async ({ run, workspaceId }) => {
|
|
1442
|
+
const ws = reads.getWorkspace(workspaceId);
|
|
1443
|
+
if (!ws)
|
|
1444
|
+
return null;
|
|
1445
|
+
if (run.target.kind !== 'new_workspace')
|
|
1446
|
+
return null;
|
|
1447
|
+
if (!run.target.sendImmediately && ws.state !== 'ready')
|
|
1448
|
+
return null;
|
|
1449
|
+
const sessions = reads.listSessions(workspaceId);
|
|
1450
|
+
if (!sessions.length)
|
|
1451
|
+
return null;
|
|
1452
|
+
const session = sessions.find(candidate => candidate.id === ws.active_session_id) ?? sessions[0];
|
|
1453
|
+
if (sessions.length !== 1) {
|
|
1454
|
+
const inspection = workflowRootInspection(ws, session);
|
|
1455
|
+
return { ...inspection, pristine: false, reason: 'The created workspace has more than one root candidate.' };
|
|
1456
|
+
}
|
|
1457
|
+
const stageIds = stagedAttachmentIdsInObjective(run.objective);
|
|
1458
|
+
if (stageIds.length) {
|
|
1459
|
+
if (!ws.worktree)
|
|
1460
|
+
return null;
|
|
1461
|
+
try {
|
|
1462
|
+
materializeStagedAttachments(STAGED_ATTACHMENTS_DIR, ws.worktree, stageIds);
|
|
1463
|
+
}
|
|
1464
|
+
catch (error) {
|
|
1465
|
+
const inspection = workflowRootInspection(ws, session);
|
|
1466
|
+
return {
|
|
1467
|
+
...inspection,
|
|
1468
|
+
pristine: false,
|
|
1469
|
+
reason: error instanceof Error ? error.message : 'The Workflow attachments could not be materialized.'
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return workflowRootInspection(ws, session);
|
|
1474
|
+
},
|
|
1475
|
+
createWorkspace: call => withWorkflowEffectGate(call, async () => {
|
|
1476
|
+
const { target } = call;
|
|
1477
|
+
const repo = reads.listRepos().find(candidate => candidate.name === target.repo);
|
|
1478
|
+
if (!repo) {
|
|
1479
|
+
throw new WorkflowCoordinatorError('workflow_not_found', `Unknown repo ${target.repo}.`, { status: 404 });
|
|
1480
|
+
}
|
|
1481
|
+
if (!repo.root_path)
|
|
1482
|
+
throw new WorkflowCoordinatorError('invalid_request', `${repo.name} has no checkout path.`);
|
|
1483
|
+
const { result, created } = await createWorkspaceAndRead('', repo.root_path, repo.name, true);
|
|
1484
|
+
if (!result.ok) {
|
|
1485
|
+
throw new WorkflowCoordinatorError('workflow_effect_failed', result.error ?? 'Workspace creation failed.', {
|
|
1486
|
+
retryable: !retryWontHelp(result.error)
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
if (!created) {
|
|
1490
|
+
throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor accepted the workspace link but no exact workspace row appeared.', { retryable: true });
|
|
1491
|
+
}
|
|
1492
|
+
return { workspaceId: created.id };
|
|
1493
|
+
}),
|
|
1494
|
+
configureSession: call => withWorkflowEffectGate(call, async () => {
|
|
1495
|
+
const { run, sessionId, role } = call;
|
|
1496
|
+
const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
|
|
1497
|
+
if (!ws || reads.sessionWorkspaceId(sessionId) !== ws.id) {
|
|
1498
|
+
throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow chat is unavailable.', {
|
|
1499
|
+
status: 404
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
const applied = await applyAgentPatch(ws, sessionId, {
|
|
1503
|
+
model: role.model,
|
|
1504
|
+
...(role.effort === undefined ? {} : { effort: role.effort }),
|
|
1505
|
+
...(role.fast === undefined ? {} : { fast: role.fast })
|
|
1506
|
+
});
|
|
1507
|
+
if (!applied.ok) {
|
|
1508
|
+
throw new WorkflowCoordinatorError('workflow_effect_failed', applied.error ?? 'Conductor rejected the frozen role settings.');
|
|
1509
|
+
}
|
|
1510
|
+
const session = reads.getSession(sessionId);
|
|
1511
|
+
if (!session || !sessionMatchesWorkflowRole(session, role)) {
|
|
1512
|
+
throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor no longer matches every frozen role setting; no fallback was selected.');
|
|
1513
|
+
}
|
|
1514
|
+
return {
|
|
1515
|
+
sessionId,
|
|
1516
|
+
agentType: session.agent_type,
|
|
1517
|
+
model: role.model,
|
|
1518
|
+
effort: session.claude_effort_level,
|
|
1519
|
+
fast: Boolean(session.fast_mode)
|
|
1520
|
+
};
|
|
1521
|
+
}),
|
|
1522
|
+
openChild: async (call) => {
|
|
1523
|
+
const { run } = call;
|
|
1524
|
+
const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
|
|
1525
|
+
if (!ws)
|
|
1526
|
+
throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow workspace is unavailable.', {
|
|
1527
|
+
status: 404
|
|
1528
|
+
});
|
|
1529
|
+
// A lock probe is read-only and happens before the durable dispatch boundary.
|
|
1530
|
+
// Otherwise a locked Mac would quarantine an effect whose child tab was never attempted.
|
|
1531
|
+
if ((await screenLocked()) === true) {
|
|
1532
|
+
throw new WorkflowCoordinatorError('workflow_effect_failed', 'The Mac is locked — unlock it and retry Workflow.');
|
|
1533
|
+
}
|
|
1534
|
+
return withWorkflowEffectGate(call, async () => {
|
|
1535
|
+
const opened = await openChat(ws);
|
|
1536
|
+
if ('error' in opened) {
|
|
1537
|
+
throw new WorkflowCoordinatorError('workflow_effect_failed', opened.result.error ?? 'Conductor did not open a tracked child chat.', { retryable: opened.retryable !== false });
|
|
1538
|
+
}
|
|
1539
|
+
if (!opened.sessionId) {
|
|
1540
|
+
throw new WorkflowCoordinatorError('workflow_effect_failed', 'Conductor opened a child tab but did not record its exact session ID.');
|
|
1541
|
+
}
|
|
1542
|
+
return { sessionId: opened.sessionId };
|
|
1543
|
+
});
|
|
1544
|
+
},
|
|
1545
|
+
captureSessionBaseline: async (workspaceId) => {
|
|
1546
|
+
const ws = reads.getWorkspace(workspaceId);
|
|
1547
|
+
if (!ws) {
|
|
1548
|
+
throw new WorkflowCoordinatorError('workflow_not_found', 'The Workflow workspace is unavailable.', {
|
|
1549
|
+
status: 404
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
return {
|
|
1553
|
+
kind: 'session_ids',
|
|
1554
|
+
workspaceId,
|
|
1555
|
+
sessionIds: reads
|
|
1556
|
+
.listSessions(workspaceId)
|
|
1557
|
+
.map(session => session.id)
|
|
1558
|
+
.sort()
|
|
1559
|
+
};
|
|
1560
|
+
},
|
|
1561
|
+
captureDeliveryCursor: async (sessionId) => workflowDeliveryCursor(sessionId),
|
|
1562
|
+
captureTranscriptCursor: async (sessionId) => ({ rowid: reads.getMessages(sessionId).cursor }),
|
|
1563
|
+
materializeHandoff: async ({ run, job }) => {
|
|
1564
|
+
const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
|
|
1565
|
+
if (!ws?.worktree || !run.rootSessionId)
|
|
1566
|
+
return undefined;
|
|
1567
|
+
const cursor = job.transcriptCursor && typeof job.transcriptCursor === 'object'
|
|
1568
|
+
? job.transcriptCursor.rowid
|
|
1569
|
+
: undefined;
|
|
1570
|
+
const entries = reads
|
|
1571
|
+
.getMessages(run.rootSessionId)
|
|
1572
|
+
.entries.filter(entry => typeof cursor !== 'number' || entry.rowid <= cursor);
|
|
1573
|
+
const rendered = renderTranscript(entries, { thinking: true, tools: false });
|
|
1574
|
+
if (!rendered.kept)
|
|
1575
|
+
return undefined;
|
|
1576
|
+
const body = [
|
|
1577
|
+
`# Workflow handoff for ${job.logicalKey}`,
|
|
1578
|
+
'',
|
|
1579
|
+
`Workflow: ${run.id}`,
|
|
1580
|
+
`Workflow job: ${job.id}`,
|
|
1581
|
+
`Root chat: ${run.rootSessionId}`,
|
|
1582
|
+
'',
|
|
1583
|
+
rendered.text
|
|
1584
|
+
].join('\n');
|
|
1585
|
+
return stableWorkflowAttachment(ws.worktree, job.id, `Workflow ${job.role} handoff.md`, body);
|
|
1586
|
+
},
|
|
1587
|
+
sendPrompt: call => sendWorkflowPrompt(call, false),
|
|
1588
|
+
returnBaton: call => sendWorkflowPrompt(call, true),
|
|
1589
|
+
resolveDeliveryReceipt: async ({ sessionId, receipt }) => {
|
|
1590
|
+
const current = reads.deliveryReceiptForId(sessionId, receipt.id);
|
|
1591
|
+
if (current?.kind === 'message')
|
|
1592
|
+
return { status: 'delivered', receipt: current };
|
|
1593
|
+
if (current?.kind === 'outbox')
|
|
1594
|
+
return { status: 'pending' };
|
|
1595
|
+
return { status: 'lost', evidence: { receiptId: receipt.id, priorKind: receipt.kind } };
|
|
1596
|
+
},
|
|
1597
|
+
readChildOutcome: async ({ job }) => {
|
|
1598
|
+
if (!job.childSessionId || !job.taskReceipt || typeof job.taskReceipt !== 'object')
|
|
1599
|
+
return null;
|
|
1600
|
+
const receipt = job.taskReceipt;
|
|
1601
|
+
if (receipt.kind !== 'message' || !Number.isSafeInteger(receipt.rowid))
|
|
1602
|
+
return null;
|
|
1603
|
+
const child = reads.getSession(job.childSessionId);
|
|
1604
|
+
if (!child) {
|
|
1605
|
+
return {
|
|
1606
|
+
kind: 'failure',
|
|
1607
|
+
code: 'session_not_found',
|
|
1608
|
+
message: 'The tracked child chat disappeared.',
|
|
1609
|
+
retryClass: 'deterministic'
|
|
1610
|
+
};
|
|
1611
|
+
}
|
|
1612
|
+
const messages = typeof receipt.turnId === 'string'
|
|
1613
|
+
? reads.getMessagesForTurn(job.childSessionId, receipt.turnId, receipt.rowid)
|
|
1614
|
+
: reads.getMessages(job.childSessionId, receipt.rowid);
|
|
1615
|
+
const assistants = messages.entries.filter(entry => entry.role === 'assistant' && entry.text.trim());
|
|
1616
|
+
const last = assistants.at(-1);
|
|
1617
|
+
if (child.status === 'error') {
|
|
1618
|
+
return {
|
|
1619
|
+
kind: 'failure',
|
|
1620
|
+
code: 'completion_failed',
|
|
1621
|
+
message: last?.text.trim() || 'The tracked child agent stopped with an error.',
|
|
1622
|
+
retryClass: 'deterministic',
|
|
1623
|
+
evidence: { assistantRowid: last?.rowid }
|
|
1624
|
+
};
|
|
1625
|
+
}
|
|
1626
|
+
if (child.status !== 'idle' || child.background_tasks.length || !last)
|
|
1627
|
+
return null;
|
|
1628
|
+
return {
|
|
1629
|
+
kind: 'success',
|
|
1630
|
+
baton: batonText(last.text),
|
|
1631
|
+
evidence: { assistantRowid: last.rowid }
|
|
1632
|
+
};
|
|
1633
|
+
},
|
|
1634
|
+
validateBeforeDispatch: async ({ run, effect }) => {
|
|
1635
|
+
if (effect.kind !== 'configure_root' && effect.kind !== 'send_root')
|
|
1636
|
+
return;
|
|
1637
|
+
const sessionId = workflowSessionId(effect);
|
|
1638
|
+
if (!sessionId) {
|
|
1639
|
+
throw new WorkflowCoordinatorError('workflow_root_not_pristine', 'The Workflow root effect lost its exact session binding before dispatch.');
|
|
1640
|
+
}
|
|
1641
|
+
assertWorkflowRootStillPristine(run, sessionId);
|
|
1642
|
+
},
|
|
1643
|
+
reconcileEffect: async ({ run, effect }) => {
|
|
1644
|
+
const sessionId = workflowSessionId(effect);
|
|
1645
|
+
if (effect.receipt && sessionId && typeof effect.receipt === 'object') {
|
|
1646
|
+
const receipt = effect.receipt;
|
|
1647
|
+
if (typeof receipt.id === 'string') {
|
|
1648
|
+
const current = reads.deliveryReceiptForId(sessionId, receipt.id);
|
|
1649
|
+
if (current)
|
|
1650
|
+
return { status: 'committed', receipt: current };
|
|
1651
|
+
return { status: 'ambiguous', evidence: { receiptId: receipt.id, state: 'missing' } };
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
if (effect.kind === 'configure_root' || effect.kind === 'configure_child') {
|
|
1655
|
+
const role = effect.inputs && typeof effect.inputs === 'object'
|
|
1656
|
+
? effect.inputs.role
|
|
1657
|
+
: undefined;
|
|
1658
|
+
const session = sessionId ? reads.getSession(sessionId) : null;
|
|
1659
|
+
if (effect.kind === 'configure_root') {
|
|
1660
|
+
const ws = run.workspaceId ? reads.getWorkspace(run.workspaceId) : null;
|
|
1661
|
+
if (!ws ||
|
|
1662
|
+
!session ||
|
|
1663
|
+
run.rootSessionId !== sessionId ||
|
|
1664
|
+
reads.sessionWorkspaceId(session.id) !== ws.id ||
|
|
1665
|
+
!workflowRootInspection(ws, session).pristine) {
|
|
1666
|
+
return { status: 'pending' };
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
if (role && session && sessionMatchesWorkflowRole(session, role)) {
|
|
1670
|
+
return { status: 'committed', receipt: { sessionId, matched: true } };
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
if (effect.kind === 'create_workspace') {
|
|
1674
|
+
const baseline = workspaceBaseline(effect.baseline);
|
|
1675
|
+
if (!baseline)
|
|
1676
|
+
return { status: 'ambiguous', evidence: { baseline: 'invalid' } };
|
|
1677
|
+
const prior = new Set(baseline.workspaceIds);
|
|
1678
|
+
const candidates = reads
|
|
1679
|
+
.listWorkspaces()
|
|
1680
|
+
.filter(workspace => workspace.repo_name === baseline.repo && !prior.has(workspace.id))
|
|
1681
|
+
.map(workflowWorkspaceCandidate);
|
|
1682
|
+
return { status: 'ambiguous', candidates, evidence: { candidateCount: candidates.length } };
|
|
1683
|
+
}
|
|
1684
|
+
if (effect.kind === 'open_child') {
|
|
1685
|
+
const baseline = sessionBaseline(effect.baseline);
|
|
1686
|
+
const ws = baseline ? reads.getWorkspace(baseline.workspaceId) : null;
|
|
1687
|
+
if (!baseline || !ws)
|
|
1688
|
+
return { status: 'ambiguous', evidence: { baseline: 'invalid' } };
|
|
1689
|
+
const prior = new Set(baseline.sessionIds);
|
|
1690
|
+
const candidates = reads
|
|
1691
|
+
.listSessions(ws.id)
|
|
1692
|
+
.filter(session => !prior.has(session.id) && workflowRootInspection(ws, session).pristine)
|
|
1693
|
+
.map(session => workflowSessionCandidate(ws, session));
|
|
1694
|
+
return { status: 'ambiguous', candidates, evidence: { candidateCount: candidates.length } };
|
|
1695
|
+
}
|
|
1696
|
+
const prompt = workflowEffectPrompt(effect);
|
|
1697
|
+
const cursor = readsDeliveryCursor(effect.cursor);
|
|
1698
|
+
if (sessionId && cursor) {
|
|
1699
|
+
const marker = workflowEffectMarker(effect);
|
|
1700
|
+
const receipt = marker
|
|
1701
|
+
? reads.deliveryReceiptContainingSince(sessionId, marker, cursor)
|
|
1702
|
+
: prompt
|
|
1703
|
+
? reads.deliveryReceiptSince(sessionId, prompt, cursor)
|
|
1704
|
+
: null;
|
|
1705
|
+
if (receipt)
|
|
1706
|
+
return { status: 'committed', receipt };
|
|
1707
|
+
}
|
|
1708
|
+
return { status: 'pending' };
|
|
1709
|
+
},
|
|
1710
|
+
validateAdoption: async ({ effect, candidate }) => {
|
|
1711
|
+
if (candidate.kind === 'workspace' && effect.kind === 'create_workspace') {
|
|
1712
|
+
const baseline = workspaceBaseline(effect.baseline);
|
|
1713
|
+
const ws = reads.getWorkspace(candidate.id);
|
|
1714
|
+
if (!baseline || !ws || baseline.workspaceIds.includes(ws.id) || ws.repo_name !== baseline.repo)
|
|
1715
|
+
return null;
|
|
1716
|
+
return { workspaceId: ws.id };
|
|
1717
|
+
}
|
|
1718
|
+
if (candidate.kind === 'session' && effect.kind === 'open_child') {
|
|
1719
|
+
const baseline = sessionBaseline(effect.baseline);
|
|
1720
|
+
const ws = baseline ? reads.getWorkspace(baseline.workspaceId) : null;
|
|
1721
|
+
const session = reads.getSession(candidate.id);
|
|
1722
|
+
if (!baseline ||
|
|
1723
|
+
!ws ||
|
|
1724
|
+
!session ||
|
|
1725
|
+
baseline.sessionIds.includes(session.id) ||
|
|
1726
|
+
reads.sessionWorkspaceId(session.id) !== ws.id ||
|
|
1727
|
+
!workflowRootInspection(ws, session).pristine) {
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
return { sessionId: session.id };
|
|
1731
|
+
}
|
|
1732
|
+
return null;
|
|
1733
|
+
},
|
|
1734
|
+
assertCompatibleRelays: async () => {
|
|
1735
|
+
const error = await workflowCompatibilityError();
|
|
1736
|
+
if (error) {
|
|
1737
|
+
throw new WorkflowCoordinatorError('workflow_incompatible_relay', error, {
|
|
1738
|
+
status: 409,
|
|
1739
|
+
retryable: true
|
|
1740
|
+
});
|
|
1741
|
+
}
|
|
1742
|
+
},
|
|
1743
|
+
dispatchMode: effect => actuator.name === 'sidecar' &&
|
|
1744
|
+
(effect.kind === 'send_root' ||
|
|
1745
|
+
effect.kind === 'send_task' ||
|
|
1746
|
+
effect.kind === 'return_baton' ||
|
|
1747
|
+
effect.kind === 'authorize_phase')
|
|
1748
|
+
? 'in_process'
|
|
1749
|
+
: 'gated_child'
|
|
1750
|
+
})
|
|
1751
|
+
: null;
|
|
1752
|
+
const WORKFLOW_RECOVERY_PHONE_ONLY = 'Workflow recovery is available only from the phone UI.';
|
|
1753
|
+
function requirePhoneWorkflowCoordinator(req, forbiddenMessage) {
|
|
1754
|
+
if (workflowClientIsMcp(req.headers))
|
|
1755
|
+
throw new WorkflowRequestError(forbiddenMessage, 403);
|
|
1756
|
+
if (!workflowCoordinator)
|
|
1757
|
+
throw new WorkflowCoordinatorError('workflow_incompatible_relay', 'Workflow is unavailable.');
|
|
1758
|
+
return workflowCoordinator;
|
|
1759
|
+
}
|
|
1760
|
+
function wakeWorkflows() {
|
|
1761
|
+
if (!workflowCoordinator)
|
|
1762
|
+
return;
|
|
1763
|
+
for (const workflowId of workflowCoordinator.runIdsNeedingWake()) {
|
|
1764
|
+
void workflowCoordinator.wake(workflowId).catch(error => {
|
|
1765
|
+
console.error(`[workflow ${workflowId}] wake failed:`, error);
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
sessionPoller.subscribe(wakeWorkflows);
|
|
1067
1770
|
const MIME = {
|
|
1068
1771
|
'.html': 'text/html; charset=utf-8',
|
|
1069
1772
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -1236,16 +1939,20 @@ class PayloadTooLargeError extends Error {
|
|
|
1236
1939
|
* in relay.log, which `/api/logs` serves to the same phone on request.
|
|
1237
1940
|
*/
|
|
1238
1941
|
function forTheClient(body) {
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1942
|
+
const scrub = (value, key) => {
|
|
1943
|
+
if (typeof value === 'string') {
|
|
1944
|
+
const withoutEvidence = withoutClientWindowEvidence(value, key);
|
|
1945
|
+
if (withoutEvidence !== value)
|
|
1946
|
+
console.warn(`[relay] ${scrubWorkflowSecrets(value)}`);
|
|
1947
|
+
return scrubWorkflowSecrets(withoutEvidence);
|
|
1948
|
+
}
|
|
1949
|
+
if (Array.isArray(value))
|
|
1950
|
+
return value.map(item => scrub(item));
|
|
1951
|
+
if (!value || typeof value !== 'object')
|
|
1952
|
+
return value;
|
|
1953
|
+
return Object.fromEntries(Object.entries(value).map(([childKey, child]) => [childKey, scrub(child, childKey)]));
|
|
1954
|
+
};
|
|
1955
|
+
return scrub(body);
|
|
1249
1956
|
}
|
|
1250
1957
|
/**
|
|
1251
1958
|
* Successful GETs are conditional + compressed to keep the phone's polling cheap.
|
|
@@ -1411,7 +2118,7 @@ async function handleMcpHttp(req, res) {
|
|
|
1411
2118
|
if (!answers.length)
|
|
1412
2119
|
return void res.writeHead(202).end();
|
|
1413
2120
|
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
1414
|
-
res.end(JSON.stringify(Array.isArray(parsed) ? answers : answers[0]));
|
|
2121
|
+
res.end(JSON.stringify(forTheClient(Array.isArray(parsed) ? answers : answers[0])));
|
|
1415
2122
|
}
|
|
1416
2123
|
const server = http.createServer(async (req, res) => {
|
|
1417
2124
|
const url = new URL(req.url ?? '/', 'http://x');
|
|
@@ -1446,6 +2153,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1446
2153
|
attachPrStatus(workspaces); // colours pr_status from cache; refreshes stale entries in the background
|
|
1447
2154
|
attachRunActivity(workspaces); // flags a live Run wrapper from a cached ps snapshot
|
|
1448
2155
|
attachDelegationState(workspaces);
|
|
2156
|
+
const workflows = attachWorkflowState(workspaces);
|
|
2157
|
+
const uiQuarantine = wireUiQuarantine();
|
|
1449
2158
|
// An undelivered first prompt rides along with its workspace: the phone renders it
|
|
1450
2159
|
// in that chat rather than tracking delivery itself (see src/firstprompt.ts).
|
|
1451
2160
|
// Prompts parked for the lock screen ride the same way, one list per workspace,
|
|
@@ -1459,6 +2168,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
1459
2168
|
}
|
|
1460
2169
|
return json(req, res, 200, {
|
|
1461
2170
|
workspaces,
|
|
2171
|
+
workflows,
|
|
2172
|
+
...(uiQuarantine ? { uiQuarantine } : {}),
|
|
2173
|
+
...(orchestration.writable
|
|
2174
|
+
? {}
|
|
2175
|
+
: { workflowWarning: scrubWorkflowSecrets(orchestrationUnavailableReason()).slice(0, 500) }),
|
|
1462
2176
|
actuator: await describeActuator(actuator),
|
|
1463
2177
|
version: update.current,
|
|
1464
2178
|
update
|
|
@@ -1592,12 +2306,175 @@ const server = http.createServer(async (req, res) => {
|
|
|
1592
2306
|
}
|
|
1593
2307
|
return json(req, res, 200, { ok: true, config: written.config });
|
|
1594
2308
|
}
|
|
2309
|
+
// This is deliberately independent of a Workflow run: cancellation cannot
|
|
2310
|
+
// make an ambiguous shared-window effect safe. Only a phone acknowledgement
|
|
2311
|
+
// after inspecting Conductor clears the relay-wide hold.
|
|
2312
|
+
if (isRoute(routes.confirmUiStable, req.method, pathname)) {
|
|
2313
|
+
if (workflowClientIsMcp(req.headers)) {
|
|
2314
|
+
throw new WorkflowRequestError('Only the phone UI can confirm that Conductor is stable.', 403);
|
|
2315
|
+
}
|
|
2316
|
+
if (!orchestration.writable) {
|
|
2317
|
+
throw new WorkflowCoordinatorError('workflow_incompatible_relay', `UI stability confirmation is disabled because ${orchestrationUnavailableReason()}.`, { status: 409 });
|
|
2318
|
+
}
|
|
2319
|
+
const request = parseConfirmUiStableRequest(await workflowRequestBody(req));
|
|
2320
|
+
const confirmed = orchestration.idempotentMutation('confirm_ui_stable', request.clientId, {
|
|
2321
|
+
confirmStable: true,
|
|
2322
|
+
createdAt: request.createdAt,
|
|
2323
|
+
...(request.actionId ? { actionId: request.actionId } : {}),
|
|
2324
|
+
...(request.effectId ? { effectId: request.effectId } : {})
|
|
2325
|
+
}, () => {
|
|
2326
|
+
const current = orchestration.getUiQuarantine();
|
|
2327
|
+
if (current.active &&
|
|
2328
|
+
(current.createdAt !== request.createdAt ||
|
|
2329
|
+
current.actionId !== request.actionId ||
|
|
2330
|
+
current.effectId !== request.effectId)) {
|
|
2331
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'The Conductor UI safety hold changed; inspect the current hold before confirming it.', { status: 409 });
|
|
2332
|
+
}
|
|
2333
|
+
orchestration.clearUiQuarantine(`phone:${request.clientId}`);
|
|
2334
|
+
return { ok: true };
|
|
2335
|
+
});
|
|
2336
|
+
return json(req, res, 200, confirmed.result);
|
|
2337
|
+
}
|
|
2338
|
+
// POST /api/workflows — the only operation that authorizes a managed
|
|
2339
|
+
// Workflow. Acceptance is durable and intentionally precedes every UI effect.
|
|
2340
|
+
if (isRoute(routes.workflows, req.method, pathname)) {
|
|
2341
|
+
if (workflowClientIsMcp(req.headers)) {
|
|
2342
|
+
throw new WorkflowRequestError('MCP cannot start a Workflow; start it from the Conductor Remote UI.', 403);
|
|
2343
|
+
}
|
|
2344
|
+
if (!workflowCoordinator) {
|
|
2345
|
+
throw new WorkflowCoordinatorError('workflow_incompatible_relay', `Workflow is disabled because ${orchestrationUnavailableReason()}.`, { status: 409 });
|
|
2346
|
+
}
|
|
2347
|
+
const request = parseStartWorkflowRequest(await workflowRequestBody(req));
|
|
2348
|
+
const replay = orchestration.getIdempotentMutation('start_workflow', request.clientId, {
|
|
2349
|
+
objective: request.objective,
|
|
2350
|
+
target: request.target
|
|
2351
|
+
});
|
|
2352
|
+
if (!replay && request.target.kind === 'new_workspace') {
|
|
2353
|
+
const stageIds = stagedAttachmentIdsInObjective(request.objective);
|
|
2354
|
+
if (stageIds.length && !stagedAttachments(STAGED_ATTACHMENTS_DIR, stageIds)) {
|
|
2355
|
+
throw new WorkflowCoordinatorError('invalid_request', 'One or more Workflow attachments are no longer staged; add them again.', { status: 409 });
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
const accepted = await workflowCoordinator.start({
|
|
2359
|
+
clientId: request.clientId,
|
|
2360
|
+
objective: request.objective,
|
|
2361
|
+
target: request.target,
|
|
2362
|
+
roles: roleStore.read(),
|
|
2363
|
+
modelGroups: modelCache.list()
|
|
2364
|
+
});
|
|
2365
|
+
queueMicrotask(() => {
|
|
2366
|
+
void workflowCoordinator.wake(accepted.workflow.id).catch(error => {
|
|
2367
|
+
console.error(`[workflow ${accepted.workflow.id}] initial wake failed:`, error);
|
|
2368
|
+
});
|
|
2369
|
+
});
|
|
2370
|
+
return json(req, res, 202, { workflow: accepted.workflow });
|
|
2371
|
+
}
|
|
2372
|
+
// POST /api/workflows/:id/delegations — the sole agent mutation. The
|
|
2373
|
+
// capability, exact root, frozen role, and phase barrier are checked together.
|
|
2374
|
+
const workflowDelegation = routeParam(routes.workflowDelegation, req.method, pathname);
|
|
2375
|
+
if (workflowDelegation) {
|
|
2376
|
+
if (!workflowCoordinator) {
|
|
2377
|
+
throw new WorkflowCoordinatorError('workflow_incompatible_relay', 'Workflow is unavailable.', {
|
|
2378
|
+
status: 409
|
|
2379
|
+
});
|
|
2380
|
+
}
|
|
2381
|
+
const request = parseWorkflowDelegateRequest(await workflowRequestBody(req), workflowDelegation);
|
|
2382
|
+
// The capability rotates after every accepted choice. Its hash therefore
|
|
2383
|
+
// doubles as a stable retry identity without adding a field to the exact tool schema.
|
|
2384
|
+
const clientId = crypto
|
|
2385
|
+
.createHash('sha256')
|
|
2386
|
+
.update(JSON.stringify([
|
|
2387
|
+
request.workflow_id,
|
|
2388
|
+
request.phase_capability,
|
|
2389
|
+
request.session_id,
|
|
2390
|
+
request.role,
|
|
2391
|
+
request.prompt
|
|
2392
|
+
]))
|
|
2393
|
+
.digest('hex');
|
|
2394
|
+
const accepted = await workflowCoordinator.delegate({
|
|
2395
|
+
clientId,
|
|
2396
|
+
workflowId: request.workflow_id,
|
|
2397
|
+
sessionId: request.session_id,
|
|
2398
|
+
phaseCapability: request.phase_capability,
|
|
2399
|
+
role: request.role,
|
|
2400
|
+
task: request.prompt,
|
|
2401
|
+
...(request.role === 'implementation' ? { planningInterpretation: request.prompt } : {})
|
|
2402
|
+
});
|
|
2403
|
+
queueMicrotask(() => {
|
|
2404
|
+
void workflowCoordinator.wake(accepted.workflow.id).catch(error => {
|
|
2405
|
+
console.error(`[workflow ${accepted.workflow.id}] delegated wake failed:`, error);
|
|
2406
|
+
});
|
|
2407
|
+
});
|
|
2408
|
+
return json(req, res, 202, {
|
|
2409
|
+
ok: true,
|
|
2410
|
+
workflowId: accepted.workflow.id,
|
|
2411
|
+
delegationId: accepted.job.id,
|
|
2412
|
+
role: accepted.job.role,
|
|
2413
|
+
model: accepted.job.resolvedRole.model
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
const retryWorkflow = routeParam(routes.workflowRetry, req.method, pathname);
|
|
2417
|
+
if (retryWorkflow) {
|
|
2418
|
+
const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
|
|
2419
|
+
const request = parseWorkflowRetryRequest(await workflowRequestBody(req));
|
|
2420
|
+
const result = await coordinator.retry({
|
|
2421
|
+
clientId: request.clientId,
|
|
2422
|
+
workflowId: retryWorkflow
|
|
2423
|
+
});
|
|
2424
|
+
queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
|
|
2425
|
+
return json(req, res, 200, { workflow: result.workflow });
|
|
2426
|
+
}
|
|
2427
|
+
const adoptWorkflow = routeParam(routes.workflowAdopt, req.method, pathname);
|
|
2428
|
+
if (adoptWorkflow) {
|
|
2429
|
+
const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
|
|
2430
|
+
const request = parseWorkflowAdoptRequest(await workflowRequestBody(req));
|
|
2431
|
+
const result = await coordinator.adopt({
|
|
2432
|
+
clientId: request.clientId,
|
|
2433
|
+
workflowId: adoptWorkflow,
|
|
2434
|
+
actionId: request.actionId,
|
|
2435
|
+
candidateId: request.workspaceId ?? request.sessionId
|
|
2436
|
+
});
|
|
2437
|
+
queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
|
|
2438
|
+
return json(req, res, 200, { workflow: result.workflow });
|
|
2439
|
+
}
|
|
2440
|
+
const replayWorkflow = routeParam(routes.workflowReplay, req.method, pathname);
|
|
2441
|
+
if (replayWorkflow) {
|
|
2442
|
+
const coordinator = requirePhoneWorkflowCoordinator(req, WORKFLOW_RECOVERY_PHONE_ONLY);
|
|
2443
|
+
const request = parseWorkflowReplayRequest(await workflowRequestBody(req));
|
|
2444
|
+
const result = await coordinator.replay({
|
|
2445
|
+
clientId: request.clientId,
|
|
2446
|
+
workflowId: replayWorkflow,
|
|
2447
|
+
actionId: request.actionId,
|
|
2448
|
+
confirmDuplicateRisk: request.confirmDuplicateRisk
|
|
2449
|
+
});
|
|
2450
|
+
queueMicrotask(() => void coordinator.wake(result.workflow.id).catch(console.error));
|
|
2451
|
+
return json(req, res, 200, { workflow: result.workflow });
|
|
2452
|
+
}
|
|
2453
|
+
const completeWorkflow = routeParam(routes.workflowComplete, req.method, pathname);
|
|
2454
|
+
if (completeWorkflow) {
|
|
2455
|
+
const coordinator = requirePhoneWorkflowCoordinator(req, 'Only the phone UI can mark a Workflow complete.');
|
|
2456
|
+
const request = parseWorkflowCompleteRequest(await workflowRequestBody(req));
|
|
2457
|
+
const result = await coordinator.complete({
|
|
2458
|
+
clientId: request.clientId,
|
|
2459
|
+
workflowId: completeWorkflow
|
|
2460
|
+
});
|
|
2461
|
+
return json(req, res, 200, { workflow: result.workflow });
|
|
2462
|
+
}
|
|
2463
|
+
const cancelWorkflow = routeParam(routes.workflow, req.method, pathname);
|
|
2464
|
+
if (cancelWorkflow) {
|
|
2465
|
+
const coordinator = requirePhoneWorkflowCoordinator(req, 'Only the phone UI can cancel a Workflow.');
|
|
2466
|
+
const clientId = url.searchParams.get('clientId');
|
|
2467
|
+
if (!clientId?.trim())
|
|
2468
|
+
throw new WorkflowRequestError('clientId is required.');
|
|
2469
|
+
const result = await coordinator.cancel({ clientId: clientId.trim(), workflowId: cancelWorkflow });
|
|
2470
|
+
return json(req, res, 200, { workflow: result.workflow });
|
|
2471
|
+
}
|
|
1595
2472
|
if (isRoute(routes.delegations, req.method, pathname)) {
|
|
1596
2473
|
const workspaceId = url.searchParams.get('workspaceId');
|
|
1597
2474
|
const workspaces = reads.listWorkspaces().filter(ws => !workspaceId || ws.id === workspaceId);
|
|
1598
2475
|
if (workspaceId && !workspaces.length)
|
|
1599
2476
|
return json(req, res, 404, { error: 'workspace not found' });
|
|
1600
|
-
const
|
|
2477
|
+
const legacy = workspaces.flatMap(ws => {
|
|
1601
2478
|
const store = delegationStore(ws);
|
|
1602
2479
|
return store
|
|
1603
2480
|
? store
|
|
@@ -1606,7 +2483,18 @@ const server = http.createServer(async (req, res) => {
|
|
|
1606
2483
|
.map(projectDelegation)
|
|
1607
2484
|
: [];
|
|
1608
2485
|
});
|
|
1609
|
-
|
|
2486
|
+
const allowedWorkspaces = new Set(workspaces.map(workspace => workspace.id));
|
|
2487
|
+
const workflows = orchestration.writable
|
|
2488
|
+
? orchestration.listWorkflowProjections().flatMap(workflow => {
|
|
2489
|
+
if (!workflow.workspaceId || !allowedWorkspaces.has(workflow.workspaceId))
|
|
2490
|
+
return [];
|
|
2491
|
+
return orchestration
|
|
2492
|
+
.listWorkflowJobs(workflow.id)
|
|
2493
|
+
.filter(job => job.state !== 'returned')
|
|
2494
|
+
.flatMap(job => projectWorkflowDelegation(workflow, job) ?? []);
|
|
2495
|
+
})
|
|
2496
|
+
: [];
|
|
2497
|
+
return json(req, res, 200, { delegations: [...legacy, ...workflows] });
|
|
1610
2498
|
}
|
|
1611
2499
|
const dismissDelegation = routeParam(routes.dismissDelegation, req.method, pathname);
|
|
1612
2500
|
if (dismissDelegation) {
|
|
@@ -1922,13 +2810,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
1922
2810
|
return json(req, res, discardStagedAttachment(STAGED_ATTACHMENTS_DIR, stagedAttachment) ? 200 : 404, {
|
|
1923
2811
|
ok: true
|
|
1924
2812
|
});
|
|
1925
|
-
// POST /api/workspaces { repo, prompt,
|
|
2813
|
+
// POST /api/workspaces { repo, prompt, model?/effort?/plan?/fast?, send? }
|
|
1926
2814
|
// — create a workspace via Conductor's deep link, then configure its first chat.
|
|
1927
2815
|
if (isRoute(routes.createWorkspace, req.method, pathname)) {
|
|
1928
2816
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
1929
2817
|
const attachmentIds = body.attachmentIds ?? [];
|
|
1930
|
-
if (
|
|
1931
|
-
return json(req, res, 400, { error: '
|
|
2818
|
+
if ('workflow' in body)
|
|
2819
|
+
return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
|
|
1932
2820
|
if (body.model !== undefined && typeof body.model !== 'string')
|
|
1933
2821
|
return json(req, res, 400, { error: 'model must be a picker label' });
|
|
1934
2822
|
if (body.effort !== undefined && typeof body.effort !== 'string')
|
|
@@ -1946,10 +2834,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
1946
2834
|
plan: body.plan,
|
|
1947
2835
|
fast: body.fast
|
|
1948
2836
|
};
|
|
1949
|
-
const hasRequestedAgent = Object.values(requestedAgent).some(value => value !== undefined);
|
|
1950
|
-
if (body.workflow && hasRequestedAgent) {
|
|
1951
|
-
return json(req, res, 400, { error: 'workflow mode owns model settings; omit model, effort, plan, and fast' });
|
|
1952
|
-
}
|
|
1953
2837
|
if (!Array.isArray(attachmentIds) || attachmentIds.some(id => typeof id !== 'string'))
|
|
1954
2838
|
return json(req, res, 400, { error: 'attachment ids must be a list of strings' });
|
|
1955
2839
|
const attachments = stagedAttachments(STAGED_ATTACHMENTS_DIR, attachmentIds);
|
|
@@ -1962,18 +2846,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
1962
2846
|
.join('\n');
|
|
1963
2847
|
if (!objective && !body.repo)
|
|
1964
2848
|
return json(req, res, 400, { error: 'need a repo or a prompt' });
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
const stored = roleStore.read();
|
|
1968
|
-
if (stored.warning)
|
|
1969
|
-
return json(req, res, 500, { error: stored.warning });
|
|
1970
|
-
const prepared = prepareWorkflowRoot(stored.config, modelCache.list(), objective);
|
|
1971
|
-
if (!prepared.ok)
|
|
1972
|
-
return json(req, res, delegationHttpStatus(prepared.error), { error: prepared.error.message });
|
|
1973
|
-
workflowRoot = prepared;
|
|
1974
|
-
}
|
|
1975
|
-
const prompt = workflowRoot?.prompt ?? objective;
|
|
1976
|
-
const agent = workflowRoot?.agent ?? requestedAgent;
|
|
2849
|
+
const prompt = objective;
|
|
2850
|
+
const agent = requestedAgent;
|
|
1977
2851
|
const configureAgent = Object.values(agent).some(value => value !== undefined);
|
|
1978
2852
|
// Resolve the repo to a real path: an unmatched `path` would silently land
|
|
1979
2853
|
// the workspace in whichever repo Conductor happens to list first.
|
|
@@ -1999,7 +2873,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
1999
2873
|
// callers into waiting.
|
|
2000
2874
|
// Whatever happens, the prompt is already pre-filled in Conductor's composer.
|
|
2001
2875
|
const settled = prompt || configureAgent
|
|
2002
|
-
? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined
|
|
2876
|
+
? firstPrompts.enqueue(created.id, prompt, body.sendImmediately !== false, attachmentIds, configureAgent ? agent : undefined)
|
|
2003
2877
|
: null;
|
|
2004
2878
|
const failed = settled && body.send === true ? await settled : null;
|
|
2005
2879
|
settled?.catch(() => undefined); // fire-and-forget: it reports failure, it never rejects
|
|
@@ -2009,7 +2883,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
2009
2883
|
workspace: reads.getWorkspace(created.id) ?? created,
|
|
2010
2884
|
pendingPrompt: prompt || undefined,
|
|
2011
2885
|
model: agent.model,
|
|
2012
|
-
workflow: workflowRoot ? { role: WORKFLOW_ROOT_ROLE, model: workflowRoot.resolvedRole.model } : undefined,
|
|
2013
2886
|
sent: body.send === true && !!prompt && !failed,
|
|
2014
2887
|
configured: body.send === true && configureAgent && !failed,
|
|
2015
2888
|
warning: failed?.error &&
|
|
@@ -2075,9 +2948,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
2075
2948
|
const ws = reads.getWorkspace(listSessionsIn);
|
|
2076
2949
|
const store = ws ? delegationStore(ws) : null;
|
|
2077
2950
|
const roles = store?.sessionRoles();
|
|
2951
|
+
const enriched = ws;
|
|
2952
|
+
if (enriched)
|
|
2953
|
+
attachWorkflowState([enriched]);
|
|
2954
|
+
const sessionRoles = { ...(roles?.sessions ?? {}), ...(enriched?.session_roles ?? {}) };
|
|
2078
2955
|
return json(req, res, 200, {
|
|
2079
2956
|
sessions: reads.listSessions(listSessionsIn),
|
|
2080
|
-
...(
|
|
2957
|
+
...(Object.keys(sessionRoles).length ? { session_roles: sessionRoles } : {})
|
|
2081
2958
|
});
|
|
2082
2959
|
}
|
|
2083
2960
|
// POST /api/workspaces/:id/sessions — open a new chat (Cmd+T) in the workspace
|
|
@@ -2329,6 +3206,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2329
3206
|
const modelsOf = routeParam(routes.models, req.method, pathname);
|
|
2330
3207
|
if (modelsOf) {
|
|
2331
3208
|
const sessionId = modelsOf;
|
|
3209
|
+
const frozen = workflowFrozenError(sessionId);
|
|
3210
|
+
if (frozen)
|
|
3211
|
+
return json(req, res, 409, frozen);
|
|
2332
3212
|
const ws = reads.getWorkspace(url.searchParams.get('workspaceId') ?? '');
|
|
2333
3213
|
if (!ws)
|
|
2334
3214
|
return json(req, res, 404, { error: 'workspace for session not found' });
|
|
@@ -2347,6 +3227,9 @@ const server = http.createServer(async (req, res) => {
|
|
|
2347
3227
|
const defaultModelOf = routeParam(routes.defaultModel, req.method, pathname);
|
|
2348
3228
|
if (defaultModelOf) {
|
|
2349
3229
|
const sessionId = defaultModelOf;
|
|
3230
|
+
const frozen = workflowFrozenError(sessionId);
|
|
3231
|
+
if (frozen)
|
|
3232
|
+
return json(req, res, 409, frozen);
|
|
2350
3233
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2351
3234
|
if (typeof body.model !== 'string' || !body.model.trim()) {
|
|
2352
3235
|
return json(req, res, 400, { error: 'model must be a picker label' });
|
|
@@ -2377,6 +3260,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2377
3260
|
if (body.effort && !EFFORT_LABELS[body.effort]) {
|
|
2378
3261
|
return json(req, res, 400, { error: `effort must be one of ${Object.keys(EFFORT_LABELS).join(', ')}` });
|
|
2379
3262
|
}
|
|
3263
|
+
const frozen = workflowFrozenError(sessionId);
|
|
3264
|
+
if (frozen && (body.model !== undefined || body.effort !== undefined || body.fast !== undefined)) {
|
|
3265
|
+
return json(req, res, 409, frozen);
|
|
3266
|
+
}
|
|
2380
3267
|
const ws = body.workspaceId
|
|
2381
3268
|
? reads.getWorkspace(body.workspaceId)
|
|
2382
3269
|
: (reads.listWorkspaces().find(w => w.active_session_id === sessionId) ?? null);
|
|
@@ -2544,28 +3431,16 @@ const server = http.createServer(async (req, res) => {
|
|
|
2544
3431
|
}
|
|
2545
3432
|
const delegateFrom = routeParam(routes.delegateTask, req.method, pathname);
|
|
2546
3433
|
if (delegateFrom) {
|
|
2547
|
-
|
|
2548
|
-
try {
|
|
2549
|
-
const parsed = JSON.parse((await readBody(req)) || '{}');
|
|
2550
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
2551
|
-
throw new Error('not an object');
|
|
2552
|
-
body = parsed;
|
|
2553
|
-
}
|
|
2554
|
-
catch {
|
|
2555
|
-
return json(req, res, 400, intakeError('invalid_request', 'could not read delegation request'));
|
|
2556
|
-
}
|
|
2557
|
-
const result = acceptDelegation(delegateFrom, body);
|
|
2558
|
-
return json(req, res, result.ok ? 202 : delegationHttpStatus(result.error), result);
|
|
3434
|
+
return json(req, res, 409, intakeError('workflow_required', 'delegate_task is available only inside a UI-authorized Workflow.'));
|
|
2559
3435
|
}
|
|
2560
|
-
// POST /api/sessions/:id/prompt
|
|
2561
|
-
//
|
|
2562
|
-
// can't come apart (and so both park together when the Mac is locked).
|
|
3436
|
+
// POST /api/sessions/:id/prompt { text, agent? } — ordinary staged settings
|
|
3437
|
+
// are applied before the prompt so the two cannot come apart.
|
|
2563
3438
|
const promptTo = routeParam(routes.sendPrompt, req.method, pathname);
|
|
2564
3439
|
if (promptTo) {
|
|
2565
3440
|
const sessionId = promptTo;
|
|
2566
3441
|
const body = JSON.parse((await readBody(req)) || '{}');
|
|
2567
|
-
if (
|
|
2568
|
-
return json(req, res, 400, { error: '
|
|
3442
|
+
if ('workflow' in body) {
|
|
3443
|
+
return json(req, res, 400, { error: 'Workflow starts through POST /api/workflows.' });
|
|
2569
3444
|
}
|
|
2570
3445
|
if (body.text !== undefined && typeof body.text !== 'string') {
|
|
2571
3446
|
return json(req, res, 400, { error: 'prompt must be a string' });
|
|
@@ -2577,8 +3452,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
2577
3452
|
return json(req, res, 400, { error: 'agent must be a settings object' });
|
|
2578
3453
|
}
|
|
2579
3454
|
const requestedAgent = body.agent && Object.keys(body.agent).length ? body.agent : undefined;
|
|
2580
|
-
|
|
2581
|
-
|
|
3455
|
+
const frozen = workflowFrozenError(sessionId);
|
|
3456
|
+
if (frozen &&
|
|
3457
|
+
requestedAgent &&
|
|
3458
|
+
(requestedAgent.model !== undefined ||
|
|
3459
|
+
requestedAgent.effort !== undefined ||
|
|
3460
|
+
requestedAgent.fast !== undefined)) {
|
|
3461
|
+
return json(req, res, 409, frozen);
|
|
2582
3462
|
}
|
|
2583
3463
|
const ws = body.workspaceId
|
|
2584
3464
|
? reads.getWorkspace(body.workspaceId)
|
|
@@ -2600,61 +3480,8 @@ const server = http.createServer(async (req, res) => {
|
|
|
2600
3480
|
console.info(`[relay] send to ${ws.branch ?? ws.id} already delivered for this tap — answering, not resending`);
|
|
2601
3481
|
}
|
|
2602
3482
|
const answer = await sendOnce.run(body.clientId, async () => {
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
if (body.workflow) {
|
|
2606
|
-
const session = reads.getSession(sessionId);
|
|
2607
|
-
const hasUserRow = reads.getMessages(sessionId).entries.some(entry => entry.role === 'user');
|
|
2608
|
-
if (!session || session.last_user_message_at || hasUserRow) {
|
|
2609
|
-
return {
|
|
2610
|
-
status: 409,
|
|
2611
|
-
body: {
|
|
2612
|
-
ok: false,
|
|
2613
|
-
strategy: actuator.name,
|
|
2614
|
-
error: 'Workflow mode can only start with a new chat’s first message.'
|
|
2615
|
-
}
|
|
2616
|
-
};
|
|
2617
|
-
}
|
|
2618
|
-
if (session.status === 'working' || session.background_tasks.length) {
|
|
2619
|
-
return {
|
|
2620
|
-
status: 409,
|
|
2621
|
-
body: {
|
|
2622
|
-
ok: false,
|
|
2623
|
-
strategy: actuator.name,
|
|
2624
|
-
error: 'Workflow mode needs an idle new chat.'
|
|
2625
|
-
}
|
|
2626
|
-
};
|
|
2627
|
-
}
|
|
2628
|
-
const stored = roleStore.read();
|
|
2629
|
-
if (stored.warning) {
|
|
2630
|
-
return { status: 500, body: { ok: false, strategy: actuator.name, error: stored.warning } };
|
|
2631
|
-
}
|
|
2632
|
-
const prepared = prepareWorkflowRoot(stored.config, modelCache.list(), rawText);
|
|
2633
|
-
if (!prepared.ok) {
|
|
2634
|
-
return {
|
|
2635
|
-
status: delegationHttpStatus(prepared.error),
|
|
2636
|
-
body: { ok: false, strategy: actuator.name, error: prepared.error.message }
|
|
2637
|
-
};
|
|
2638
|
-
}
|
|
2639
|
-
try {
|
|
2640
|
-
const assigned = assignWorkflowRoot(ws, sessionId, prepared.role, Date.now());
|
|
2641
|
-
if (!assigned.ok) {
|
|
2642
|
-
return { status: 409, body: { ok: false, strategy: actuator.name, error: assigned.error } };
|
|
2643
|
-
}
|
|
2644
|
-
}
|
|
2645
|
-
catch (err) {
|
|
2646
|
-
return {
|
|
2647
|
-
status: 409,
|
|
2648
|
-
body: {
|
|
2649
|
-
ok: false,
|
|
2650
|
-
strategy: actuator.name,
|
|
2651
|
-
error: err instanceof Error ? err.message : String(err)
|
|
2652
|
-
}
|
|
2653
|
-
};
|
|
2654
|
-
}
|
|
2655
|
-
text = prepared.prompt;
|
|
2656
|
-
agent = prepared.agent;
|
|
2657
|
-
}
|
|
3483
|
+
const text = rawText;
|
|
3484
|
+
const agent = requestedAgent;
|
|
2658
3485
|
// A failed first-prompt entry offers the same Retry button as an ordinary
|
|
2659
3486
|
// prompt. If staging had been the failure, put its files in place before
|
|
2660
3487
|
// that retry reaches the attachment tokens.
|
|
@@ -2925,6 +3752,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
2925
3752
|
catch (err) {
|
|
2926
3753
|
if (err instanceof PayloadTooLargeError)
|
|
2927
3754
|
return json(req, res, 413, { error: err.message });
|
|
3755
|
+
const workflowError = workflowHttpError(err);
|
|
3756
|
+
if (workflowError) {
|
|
3757
|
+
console.warn(`[workflow] ${req.method} ${pathname}: ${workflowError.error.code}: ${workflowError.error.message}`);
|
|
3758
|
+
return json(req, res, workflowError.status, { ok: false, error: workflowError.error });
|
|
3759
|
+
}
|
|
2928
3760
|
// A refused UI turn is not a server fault and a retry is the right move, so it gets
|
|
2929
3761
|
// 503 + Retry-After rather than a 500 that reads as "the relay is broken".
|
|
2930
3762
|
if (err instanceof UiBusyError) {
|
|
@@ -2942,6 +3774,13 @@ server.listen(cfg.port, cfg.host, () => {
|
|
|
2942
3774
|
console.info(` voice: 127.0.0.1:${voicePort()}${voiceBroker ? '' : ' (waiting for OpenAI config)'}`);
|
|
2943
3775
|
void voiceBroker?.restore();
|
|
2944
3776
|
});
|
|
3777
|
+
if (orchestration.writable) {
|
|
3778
|
+
setInterval(() => orchestration.heartbeatRelayInstance(relayIdentity), 2_000).unref();
|
|
3779
|
+
setInterval(() => void recoverUiLease(), 2_000).unref();
|
|
3780
|
+
setInterval(wakeWorkflows, 2_000).unref();
|
|
3781
|
+
setInterval(() => orchestration.compactTerminalRuns({ olderThan: Date.now() - 30 * 24 * 60 * 60_000 }), 24 * 60 * 60_000).unref();
|
|
3782
|
+
queueMicrotask(() => void recoverUiLease().finally(wakeWorkflows));
|
|
3783
|
+
}
|
|
2945
3784
|
// Under `yarn dev` the app comes from Vite and only /api comes from here, so the URL worth
|
|
2946
3785
|
// printing is Vite's — carrying the token, which Vite itself has no way to print.
|
|
2947
3786
|
const dev = cfg.devWebPort !== undefined;
|