conductor-remote 1.116.0 → 1.118.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 +10 -8
- package/dist/assets/{PierrePatch-BPp5Cf-p.js → PierrePatch-D4YbKD8Q.js} +1 -1
- package/dist/assets/index-BRzsSW8g.css +1 -0
- package/dist/assets/index-BaS6qXUW.js +65 -0
- package/dist/index.html +2 -2
- package/dist/push-sw.js +2 -2
- package/dist/sw.js +1 -1
- package/dist-node/scripts/nosleep-setup.js +2 -2
- package/dist-node/scripts/nosleep.js +3 -3
- package/dist-node/scripts/service/commands.js +32 -0
- package/dist-node/scripts/service/configuration.js +205 -0
- package/dist-node/scripts/service/environment.js +59 -0
- package/dist-node/scripts/service/flags.js +45 -0
- package/dist-node/scripts/service/installation.js +91 -0
- package/dist-node/scripts/service/launch-agent.js +134 -0
- package/dist-node/scripts/service/network.js +244 -0
- package/dist-node/scripts/service/presentation.js +101 -0
- package/dist-node/scripts/service/voice.js +113 -0
- package/dist-node/scripts/service.js +5 -980
- package/dist-node/src/{agent-config.js → agents/agent-config.js} +1 -1
- package/dist-node/src/{model-cache.js → agents/model-cache.js} +1 -1
- package/dist-node/src/{roles.js → agents/roles.js} +3 -3
- package/dist-node/src/db.js +1 -1
- package/dist-node/src/{parked.js → delivery/parked.js} +1 -1
- package/dist-node/src/{dev-server.js → dev-server/controller.js} +8 -291
- package/dist-node/src/dev-server/ports.js +63 -0
- package/dist-node/src/dev-server/processes.js +73 -0
- package/dist-node/src/dev-server/proxy.js +158 -0
- package/dist-node/src/{run-activity.js → dev-server/run-activity.js} +2 -2
- package/dist-node/src/dev-server/types.js +1 -0
- package/dist-node/src/{file-preview.js → files/file-preview.js} +1 -1
- package/dist-node/src/{change-stats.js → git/change-stats.js} +1 -1
- package/dist-node/src/{git.js → git/diff.js} +1 -1
- package/dist-node/src/{autoupdate.js → host/autoupdate.js} +1 -1
- package/dist-node/src/{funnel-watchdog.js → host/funnel-watchdog.js} +4 -4
- package/dist-node/src/{logbuf.js → host/logbuf.js} +1 -1
- package/dist-node/src/{nosleep-helper.js → host/nosleep-helper.js} +1 -1
- package/dist-node/src/{tailscale.js → host/tailscale.js} +1 -1
- package/dist-node/src/http/router-types.js +2 -0
- package/dist-node/src/http/router.js +78 -0
- package/dist-node/src/http/routes/create-workspace.js +90 -0
- package/dist-node/src/http/routes/files.js +53 -0
- package/dist-node/src/http/routes/prompts.js +363 -0
- package/dist-node/src/http/routes/sessions.js +279 -0
- package/dist-node/src/http/routes/state.js +186 -0
- package/dist-node/src/http/routes/system.js +220 -0
- package/dist-node/src/http/routes/voice.js +117 -0
- package/dist-node/src/http/routes/workflows.js +248 -0
- package/dist-node/src/http/routes/workspaces.js +267 -0
- package/dist-node/src/http/services/base.js +252 -0
- package/dist-node/src/http/services/delegations.js +301 -0
- package/dist-node/src/http/services/delivery.js +433 -0
- package/dist-node/src/http/services/files.js +199 -0
- package/dist-node/src/http/services/mcp.js +94 -0
- package/dist-node/src/http/services/responses.js +120 -0
- package/dist-node/src/http/services/voice.js +147 -0
- package/dist-node/src/http/services/workflow-probes.js +215 -0
- package/dist-node/src/http/services/workflow-state.js +195 -0
- package/dist-node/src/http/services/workflow.js +366 -0
- package/dist-node/src/http/services.js +35 -0
- package/dist-node/src/mcp/arguments.js +14 -0
- package/dist-node/src/mcp/dispatcher.js +56 -0
- package/dist-node/src/mcp/formatters.js +110 -0
- package/dist-node/src/mcp/protocol.js +7 -0
- package/dist-node/src/mcp/registry.js +66 -0
- package/dist-node/src/mcp/tools/agent-options.js +122 -0
- package/dist-node/src/mcp/tools/chat-actions.js +194 -0
- package/dist-node/src/mcp/tools/chats.js +194 -0
- package/dist-node/src/mcp/tools/delegation.js +135 -0
- package/dist-node/src/mcp/tools/system.js +135 -0
- package/dist-node/src/mcp/tools/voice.js +136 -0
- package/dist-node/src/mcp/tools/workspaces.js +259 -0
- package/dist-node/src/mcp/types.js +1 -0
- package/dist-node/src/mcp.js +6 -4
- package/dist-node/src/{notify.js → notifications/notify.js} +5 -5
- package/dist-node/src/{webpush.js → notifications/webpush.js} +1 -1
- package/dist-node/src/orchestration/delegation/codec.js +201 -0
- package/dist-node/src/{delegation-intake.js → orchestration/delegation/intake.js} +2 -2
- package/dist-node/src/{delegation-prompt.js → orchestration/delegation/prompt.js} +1 -1
- package/dist-node/src/orchestration/delegation/queue.js +255 -0
- package/dist-node/src/orchestration/delegation/store.js +111 -0
- package/dist-node/src/orchestration/delegation/types.js +2 -0
- package/dist-node/src/orchestration/persistence/capabilities.js +109 -0
- package/dist-node/src/orchestration/persistence/codecs.js +148 -0
- package/dist-node/src/orchestration/persistence/connection.js +205 -0
- package/dist-node/src/orchestration/persistence/db.js +101 -0
- package/dist-node/src/orchestration/persistence/effect-recovery.js +227 -0
- package/dist-node/src/orchestration/persistence/effect-transitions.js +157 -0
- package/dist-node/src/orchestration/persistence/effects.js +109 -0
- package/dist-node/src/orchestration/persistence/errors.js +39 -0
- package/dist-node/src/orchestration/persistence/events.js +29 -0
- package/dist-node/src/orchestration/persistence/idempotency.js +82 -0
- package/dist-node/src/orchestration/persistence/job-attempts.js +114 -0
- package/dist-node/src/orchestration/persistence/jobs.js +159 -0
- package/dist-node/src/orchestration/persistence/projections.js +190 -0
- package/dist-node/src/orchestration/persistence/quarantine.js +46 -0
- package/dist-node/src/orchestration/persistence/records.js +115 -0
- package/dist-node/src/orchestration/persistence/relays.js +78 -0
- package/dist-node/src/orchestration/persistence/runs.js +156 -0
- package/dist-node/src/{orchestration-schema.js → orchestration/persistence/schema.js} +1 -1
- package/dist-node/src/orchestration/persistence/types.js +1 -0
- package/dist-node/src/orchestration/persistence/ui-lease.js +197 -0
- package/dist-node/src/orchestration/persistence/values.js +114 -0
- package/dist-node/src/orchestration/workflow/commands.js +309 -0
- package/dist-node/src/orchestration/workflow/coordinator.js +28 -0
- package/dist-node/src/orchestration/workflow/effect-recovery.js +125 -0
- package/dist-node/src/orchestration/workflow/effects.js +307 -0
- package/dist-node/src/orchestration/workflow/errors.js +13 -0
- package/dist-node/src/orchestration/workflow/helpers.js +154 -0
- package/dist-node/src/orchestration/workflow/job-dispatch.js +312 -0
- package/dist-node/src/orchestration/workflow/job-results.js +280 -0
- package/dist-node/src/{workflow.js → orchestration/workflow/prompts.js} +2 -2
- package/dist-node/src/orchestration/workflow/root.js +293 -0
- package/dist-node/src/orchestration/workflow/start.js +99 -0
- package/dist-node/src/orchestration/workflow/state.js +105 -0
- package/dist-node/src/orchestration/workflow/types.js +1 -0
- package/dist-node/src/orchestration/workflow/wake.js +164 -0
- package/dist-node/src/prefs.js +1 -1
- package/dist-node/src/{background-tasks.js → reads/background-tasks.js} +1 -1
- package/dist-node/src/reads/messages.js +362 -0
- package/dist-node/src/reads/repository.js +87 -0
- package/dist-node/src/reads/sessions.js +165 -0
- package/dist-node/src/reads/types.js +1 -0
- package/dist-node/src/reads/workspaces.js +230 -0
- package/dist-node/src/reads/worktrees.js +45 -0
- package/dist-node/src/routes.js +5 -5
- package/dist-node/src/{search.js → search/coordinator.js} +4 -4
- package/dist-node/src/{search-worker.js → search/worker.js} +3 -3
- package/dist-node/src/server.js +14 -3930
- package/dist-node/src/settings.js +1 -1
- package/dist-node/src/shared.js +130 -9
- package/dist-node/src/{transcript.js → transcript/parser.js} +4 -124
- package/dist-node/src/{tool-usage-worker.js → usage/tool-usage-worker.js} +1 -1
- package/dist-node/src/{tool-usage.js → usage/tool-usage.js} +1 -1
- package/dist-node/src/voice/brief.js +22 -50
- package/dist-node/src/voice/context.js +1 -1
- package/dist-node/src/voice/dates.js +38 -0
- package/dist-node/src/voice/history.js +14 -5
- package/dist-node/src/voice/prompt.js +10 -9
- package/dist-node/src/voice/recall.js +115 -0
- package/dist-node/src/{speech.js → voice/speech.js} +1 -1
- package/dist-node/src/voice/tools.js +92 -3
- package/dist-node/src/voice/webrtc.js +1 -1
- package/dist-node/src/wire.js +2 -2
- package/dist-node/src/writes/actuator.js +124 -0
- package/dist-node/src/writes/agent-options.js +151 -0
- package/dist-node/src/writes/applescript/agent-options.applescript +334 -0
- package/dist-node/src/writes/applescript/chat-targeting.applescript +149 -0
- package/dist-node/src/writes/applescript/composer.applescript +195 -0
- package/dist-node/src/writes/applescript/hotspot.applescript +155 -0
- package/dist-node/src/writes/applescript/lifecycle.applescript +266 -0
- package/dist-node/src/writes/applescript/run-tasks.applescript +208 -0
- package/dist-node/src/writes/applescript/source.js +52 -0
- package/dist-node/src/writes/applescript/window.applescript +436 -0
- package/dist-node/src/writes/applescript/workspace-status.applescript +177 -0
- package/dist-node/src/writes/applescript/workspace-targeting.applescript +359 -0
- package/dist-node/src/writes/chats.js +160 -0
- package/dist-node/src/writes/guards.js +77 -0
- package/dist-node/src/writes/runner.js +67 -0
- package/dist-node/src/{sidecar.js → writes/sidecar.js} +1 -1
- package/dist-node/src/writes/system.js +81 -0
- package/dist-node/src/writes/targeting.js +121 -0
- package/dist-node/src/writes/types.js +1 -0
- package/dist-node/src/writes/ui-lock.js +229 -0
- package/dist-node/src/writes/workspaces.js +233 -0
- package/docs/voice-setup.md +34 -12
- package/package.json +2 -2
- package/dist/assets/index-DNLdIfZ2.css +0 -1
- package/dist/assets/index-QJR7tn2I.js +0 -61
- package/dist-node/src/conductor.applescript +0 -2279
- package/dist-node/src/delegations.js +0 -557
- package/dist-node/src/mcp-tools.js +0 -1319
- package/dist-node/src/orchestration-db.js +0 -2186
- package/dist-node/src/reads.js +0 -787
- package/dist-node/src/workflow-coordinator.js +0 -2119
- package/dist-node/src/writes.js +0 -1223
- /package/dist-node/src/{conductor-settings.js → agents/conductor-settings.js} +0 -0
- /package/dist-node/src/{firstprompt.js → delivery/firstprompt.js} +0 -0
- /package/dist-node/src/{sendonce.js → delivery/sendonce.js} +0 -0
- /package/dist-node/src/{preview-urls.js → dev-server/preview-urls.js} +0 -0
- /package/dist-node/src/{run-configs.js → dev-server/run-configs.js} +0 -0
- /package/dist-node/src/{attachments.js → files/attachments.js} +0 -0
- /package/dist-node/src/{icons.js → files/icons.js} +0 -0
- /package/dist-node/src/{staged-attachments.js → files/staged-attachments.js} +0 -0
- /package/dist-node/src/{fork-workspace.js → git/fork-workspace.js} +0 -0
- /package/dist-node/src/{merge.js → git/merge.js} +0 -0
- /package/dist-node/src/{pr.js → git/pr.js} +0 -0
- /package/dist-node/src/{nosleep.js → host/nosleep.js} +0 -0
- /package/dist-node/src/{relay-processes.js → host/relay-processes.js} +0 -0
- /package/dist-node/src/{ui-lease-watchdog.js → host/ui-lease-watchdog.js} +0 -0
- /package/dist-node/src/{wifi.js → host/wifi.js} +0 -0
- /package/dist-node/src/{orchestration-schema.generated.js → orchestration/persistence/schema.generated.js} +0 -0
- /package/dist-node/src/{workflow-effect-runner.js → orchestration/workflow/effect-runner.js} +0 -0
- /package/dist-node/src/{workflow-http.js → orchestration/workflow/http.js} +0 -0
- /package/dist-node/src/{workflow-machine.js → orchestration/workflow/machine.js} +0 -0
- /package/dist-node/src/{session-poller.js → reads/session-poller.js} +0 -0
- /package/dist-node/src/{context-breakdown.js → transcript/context-breakdown.js} +0 -0
- /package/dist-node/src/{chat-cursor.js → transcript/cursor.js} +0 -0
- /package/dist-node/src/{plan-usage.js → usage/plan-usage.js} +0 -0
- /package/dist-node/src/{tool-usage-service.js → usage/tool-usage-service.js} +0 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { hashCapabilityToken, WorkflowTransitionError } from "../persistence/db.js";
|
|
3
|
+
import { clearMatchingQuarantine, reconcileBlockedEffect, requireSameAmbiguousEffect } from "./effect-recovery.js";
|
|
4
|
+
import { WorkflowCoordinatorError } from "./errors.js";
|
|
5
|
+
import { actionOrdinal, cleanUnknown, guardJobs, isDeliveryReceipt, privateCorrelationBlock, promptRole, workflowEffectCorrelationMarker } from "./helpers.js";
|
|
6
|
+
import { assertWorkflowDelegation, isTerminalWorkflowJobState, workflowDelegationTransition } from "./machine.js";
|
|
7
|
+
import { workflowChildPrompt } from "./prompts.js";
|
|
8
|
+
import { assertCompatible, heartbeat, projection, requireBlockedAction, requireBlockedRun, requireEffect, requireRun } from "./state.js";
|
|
9
|
+
export async function delegate(context, input) {
|
|
10
|
+
const tokenHash = hashCapabilityToken(input.phaseCapability);
|
|
11
|
+
const request = {
|
|
12
|
+
workflowId: input.workflowId,
|
|
13
|
+
sessionId: input.sessionId,
|
|
14
|
+
tokenHash,
|
|
15
|
+
role: input.role,
|
|
16
|
+
task: input.task,
|
|
17
|
+
planningInterpretation: input.planningInterpretation
|
|
18
|
+
};
|
|
19
|
+
const replay = context.db.getIdempotentMutation('workflow_delegate', input.clientId, request);
|
|
20
|
+
if (replay) {
|
|
21
|
+
const job = context.db.getWorkflowJob(replay.result.jobId);
|
|
22
|
+
if (!job)
|
|
23
|
+
throw new WorkflowTransitionError('accepted Workflow job disappeared');
|
|
24
|
+
return { replayed: true, job, workflow: projection(context, replay.result.runId) };
|
|
25
|
+
}
|
|
26
|
+
heartbeat(context);
|
|
27
|
+
if (!input.task.trim())
|
|
28
|
+
throw new WorkflowCoordinatorError('invalid_request', 'Delegation needs a focused task.', { status: 400 });
|
|
29
|
+
const initial = context.db.getWorkflowRun(input.workflowId);
|
|
30
|
+
if (!initial)
|
|
31
|
+
throw new WorkflowCoordinatorError('workflow_not_found', 'Workflow does not exist.', { status: 404 });
|
|
32
|
+
const jobId = randomUUID();
|
|
33
|
+
const transcriptCursor = await context.deps.captureTranscriptCursor?.(input.sessionId);
|
|
34
|
+
const deliveryCursor = await context.deps.captureDeliveryCursor(input.sessionId);
|
|
35
|
+
const role = initial.roles[input.role];
|
|
36
|
+
const prompt = [
|
|
37
|
+
workflowChildPrompt({
|
|
38
|
+
roleName: input.role,
|
|
39
|
+
objective: initial.objective,
|
|
40
|
+
role: promptRole(role),
|
|
41
|
+
task: input.task
|
|
42
|
+
}),
|
|
43
|
+
privateCorrelationBlock(initial.id, `job:${jobId}`)
|
|
44
|
+
].join('\n\n');
|
|
45
|
+
const mutation = context.db.idempotentMutation('workflow_delegate', input.clientId, request, () => {
|
|
46
|
+
const run = requireRun(context, input.workflowId);
|
|
47
|
+
const jobs = context.db.listWorkflowJobs(run.id);
|
|
48
|
+
const capability = context.db.getWorkflowCapability(tokenHash);
|
|
49
|
+
const claims = capability &&
|
|
50
|
+
(capability.phase === 'exploring' || capability.phase === 'planning' || capability.phase === 'reviewing')
|
|
51
|
+
? {
|
|
52
|
+
runId: capability.runId,
|
|
53
|
+
rootSessionId: capability.rootSessionId,
|
|
54
|
+
cycle: capability.cycle,
|
|
55
|
+
revision: capability.revision,
|
|
56
|
+
phase: capability.phase,
|
|
57
|
+
allowedRoles: capability.allowedRoles,
|
|
58
|
+
consumed: capability.consumedAt !== undefined,
|
|
59
|
+
revoked: capability.revokedAt !== undefined
|
|
60
|
+
}
|
|
61
|
+
: null;
|
|
62
|
+
const guardRun = {
|
|
63
|
+
id: run.id,
|
|
64
|
+
rootSessionId: run.rootSessionId ?? null,
|
|
65
|
+
phase: run.phase,
|
|
66
|
+
cycle: run.cycle,
|
|
67
|
+
revision: run.revision
|
|
68
|
+
};
|
|
69
|
+
assertWorkflowDelegation(guardRun, claims, { sessionId: input.sessionId, role: input.role }, guardJobs(jobs));
|
|
70
|
+
const transition = workflowDelegationTransition(guardRun, input.role);
|
|
71
|
+
const ordinal = actionOrdinal(jobs, input.role, transition.cycle);
|
|
72
|
+
const logicalKey = `${transition.logicalPrefix}:${transition.cycle}:${ordinal}`;
|
|
73
|
+
context.db.consumeWorkflowCapability({
|
|
74
|
+
tokenHash,
|
|
75
|
+
runId: run.id,
|
|
76
|
+
rootSessionId: input.sessionId,
|
|
77
|
+
role: input.role,
|
|
78
|
+
expectedPhase: run.phase,
|
|
79
|
+
expectedCycle: run.cycle,
|
|
80
|
+
expectedRevision: run.revision,
|
|
81
|
+
eventKey: `capability-consumed:${logicalKey}`
|
|
82
|
+
});
|
|
83
|
+
const created = context.db.createWorkflowJob({
|
|
84
|
+
id: jobId,
|
|
85
|
+
runId: run.id,
|
|
86
|
+
logicalKey,
|
|
87
|
+
role: input.role,
|
|
88
|
+
cycle: transition.cycle,
|
|
89
|
+
revision: transition.revision,
|
|
90
|
+
resolvedRole: role,
|
|
91
|
+
prompt,
|
|
92
|
+
state: 'queued',
|
|
93
|
+
transcriptCursor,
|
|
94
|
+
expectedCancellationGeneration: run.cancellationGeneration,
|
|
95
|
+
eventKey: `delegated:${logicalKey}`
|
|
96
|
+
});
|
|
97
|
+
const advanced = context.db.transitionWorkflowRun({
|
|
98
|
+
runId: run.id,
|
|
99
|
+
expectedPhase: run.phase,
|
|
100
|
+
expectedCancellationGeneration: run.cancellationGeneration,
|
|
101
|
+
phase: transition.phase,
|
|
102
|
+
cycle: transition.cycle,
|
|
103
|
+
revision: transition.revision,
|
|
104
|
+
...(input.role === 'implementation'
|
|
105
|
+
? { planningInterpretation: input.planningInterpretation ?? input.task }
|
|
106
|
+
: {}),
|
|
107
|
+
eventKey: `delegation-transition:${logicalKey}`,
|
|
108
|
+
eventType: 'workflow_delegation_accepted',
|
|
109
|
+
eventData: { jobId: created.job.id, logicalKey, role: input.role }
|
|
110
|
+
});
|
|
111
|
+
context.db.revokeWorkflowCapabilities(run.id, `revoke-after-delegation:${logicalKey}`, run.phase);
|
|
112
|
+
if (input.role === 'exploration') {
|
|
113
|
+
const actionId = `authorize:exploring:${advanced.cycle}:${advanced.revision}`;
|
|
114
|
+
context.db.prepareWorkflowEffect({
|
|
115
|
+
runId: run.id,
|
|
116
|
+
actionId,
|
|
117
|
+
kind: 'authorize_phase',
|
|
118
|
+
target: { sessionId: input.sessionId },
|
|
119
|
+
inputs: {
|
|
120
|
+
grant: {
|
|
121
|
+
phase: 'exploring',
|
|
122
|
+
cycle: advanced.cycle,
|
|
123
|
+
revision: advanced.revision,
|
|
124
|
+
allowedRoles: ['exploration']
|
|
125
|
+
},
|
|
126
|
+
correlationMarker: workflowEffectCorrelationMarker(run.id, actionId)
|
|
127
|
+
},
|
|
128
|
+
cursor: deliveryCursor,
|
|
129
|
+
expectedCancellationGeneration: run.cancellationGeneration,
|
|
130
|
+
eventKey: `prepare:${actionId}`
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return { runId: run.id, jobId: created.job.id };
|
|
134
|
+
}, { runId: input.workflowId });
|
|
135
|
+
const job = context.db.getWorkflowJob(mutation.result.jobId);
|
|
136
|
+
if (!job)
|
|
137
|
+
throw new WorkflowTransitionError('accepted Workflow job disappeared');
|
|
138
|
+
return { replayed: mutation.replayed, job, workflow: projection(context, input.workflowId) };
|
|
139
|
+
}
|
|
140
|
+
export async function retry(context, input) {
|
|
141
|
+
const replay = context.db.getIdempotentMutation('workflow_retry', input.clientId, input);
|
|
142
|
+
if (replay)
|
|
143
|
+
return { replayed: true, workflow: projection(context, replay.result.runId) };
|
|
144
|
+
heartbeat(context);
|
|
145
|
+
await assertCompatible(context);
|
|
146
|
+
const mutation = context.db.idempotentMutation('workflow_retry', input.clientId, input, () => {
|
|
147
|
+
const run = requireBlockedRun(context, input.workflowId);
|
|
148
|
+
const actionId = run.blocked?.actionId;
|
|
149
|
+
if (run.blocked?.retryClass !== 'deterministic') {
|
|
150
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'This blocked action is not safely retryable.');
|
|
151
|
+
}
|
|
152
|
+
if (actionId.startsWith('job:')) {
|
|
153
|
+
const jobId = actionId.slice(4);
|
|
154
|
+
context.db.updateWorkflowJob({
|
|
155
|
+
jobId,
|
|
156
|
+
expectedStates: ['failed'],
|
|
157
|
+
expectedCancellationGeneration: run.cancellationGeneration,
|
|
158
|
+
state: 'queued',
|
|
159
|
+
childSessionId: null,
|
|
160
|
+
outcome: null,
|
|
161
|
+
taskReceipt: null,
|
|
162
|
+
batonReceipt: null,
|
|
163
|
+
clearOwner: true,
|
|
164
|
+
eventKey: `job-retry:${jobId}:${input.clientId}`,
|
|
165
|
+
eventType: 'workflow_job_retry_queued'
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
else if (actionId.startsWith('compatibility:') || actionId === 'bind-root') {
|
|
169
|
+
// No external intent exists for these read-only gates; unblocking is the retry.
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
context.db.retryWorkflowEffect(run.id, actionId, `effect-retry:${actionId}:${input.clientId}`);
|
|
173
|
+
}
|
|
174
|
+
context.db.transitionWorkflowRun({
|
|
175
|
+
runId: run.id,
|
|
176
|
+
expectedPhase: 'blocked',
|
|
177
|
+
expectedCancellationGeneration: run.cancellationGeneration,
|
|
178
|
+
phase: run.blocked?.resumePhase,
|
|
179
|
+
blocked: null,
|
|
180
|
+
eventKey: `workflow-retry:${actionId}:${input.clientId}`,
|
|
181
|
+
eventType: 'workflow_retry_accepted'
|
|
182
|
+
});
|
|
183
|
+
return { runId: run.id };
|
|
184
|
+
}, { runId: input.workflowId });
|
|
185
|
+
return { replayed: mutation.replayed, workflow: projection(context, input.workflowId) };
|
|
186
|
+
}
|
|
187
|
+
export async function adopt(context, input) {
|
|
188
|
+
const replay = context.db.getIdempotentMutation('workflow_adopt', input.clientId, input);
|
|
189
|
+
if (replay)
|
|
190
|
+
return { replayed: true, workflow: projection(context, replay.result.runId) };
|
|
191
|
+
heartbeat(context);
|
|
192
|
+
const run = requireBlockedAction(context, input.workflowId, input.actionId);
|
|
193
|
+
const candidate = run.blocked?.candidates?.find(item => item.id === input.candidateId);
|
|
194
|
+
if (!candidate)
|
|
195
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'That saved adoption candidate is unavailable.');
|
|
196
|
+
const effect = requireEffect(context, run.id, input.actionId);
|
|
197
|
+
if (effect.state !== 'ambiguous' || !context.deps.validateAdoption) {
|
|
198
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'This action cannot be adopted.');
|
|
199
|
+
}
|
|
200
|
+
const receipt = await context.deps.validateAdoption({ run, effect, candidate });
|
|
201
|
+
if (receipt === null)
|
|
202
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'The candidate no longer validates.');
|
|
203
|
+
const mutation = context.db.idempotentMutation('workflow_adopt', input.clientId, input, () => {
|
|
204
|
+
const current = requireBlockedAction(context, input.workflowId, input.actionId);
|
|
205
|
+
const currentEffect = requireSameAmbiguousEffect(context, current.id, input.actionId, effect);
|
|
206
|
+
context.db.markWorkflowEffectCommitted({
|
|
207
|
+
runId: current.id,
|
|
208
|
+
actionId: input.actionId,
|
|
209
|
+
receipt: cleanUnknown(receipt),
|
|
210
|
+
eventKey: `effect-adopted:${input.actionId}:${input.clientId}`
|
|
211
|
+
});
|
|
212
|
+
context.db.transitionWorkflowRun({
|
|
213
|
+
runId: current.id,
|
|
214
|
+
expectedPhase: 'blocked',
|
|
215
|
+
expectedCancellationGeneration: current.cancellationGeneration,
|
|
216
|
+
phase: current.blocked?.resumePhase,
|
|
217
|
+
blocked: null,
|
|
218
|
+
eventKey: `workflow-adopted:${input.actionId}:${input.clientId}`,
|
|
219
|
+
eventType: 'workflow_effect_adopted',
|
|
220
|
+
eventData: { candidateId: candidate.id }
|
|
221
|
+
});
|
|
222
|
+
clearMatchingQuarantine(context, currentEffect, `adopt:${input.clientId}`);
|
|
223
|
+
return { runId: current.id };
|
|
224
|
+
}, { runId: input.workflowId, actionId: input.actionId });
|
|
225
|
+
return { replayed: mutation.replayed, workflow: projection(context, input.workflowId) };
|
|
226
|
+
}
|
|
227
|
+
export async function replay(context, input) {
|
|
228
|
+
const prior = context.db.getIdempotentMutation('workflow_replay_ambiguous', input.clientId, input);
|
|
229
|
+
if (prior)
|
|
230
|
+
return { replayed: true, workflow: projection(context, prior.result.runId) };
|
|
231
|
+
heartbeat(context);
|
|
232
|
+
if (input.confirmDuplicateRisk !== true) {
|
|
233
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'Risky replay requires explicit duplicate-risk confirmation.');
|
|
234
|
+
}
|
|
235
|
+
const blocked = requireBlockedAction(context, input.workflowId, input.actionId);
|
|
236
|
+
const effect = requireEffect(context, blocked.id, input.actionId);
|
|
237
|
+
await reconcileBlockedEffect(context, blocked);
|
|
238
|
+
if (requireRun(context, input.workflowId).phase !== 'blocked') {
|
|
239
|
+
const reconciled = context.db.idempotentMutation('workflow_replay_ambiguous', input.clientId, input, () => ({ runId: input.workflowId }), { runId: input.workflowId, actionId: input.actionId });
|
|
240
|
+
return { replayed: reconciled.replayed, workflow: projection(context, input.workflowId) };
|
|
241
|
+
}
|
|
242
|
+
const mutation = context.db.idempotentMutation('workflow_replay_ambiguous', input.clientId, input, () => {
|
|
243
|
+
const run = requireBlockedAction(context, input.workflowId, input.actionId);
|
|
244
|
+
if (run.blocked?.retryClass !== 'ambiguous') {
|
|
245
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'This action is not an ambiguous effect.');
|
|
246
|
+
}
|
|
247
|
+
if (run.blocked.candidates?.length) {
|
|
248
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'Validate and adopt a saved candidate instead of risking a duplicate replay.');
|
|
249
|
+
}
|
|
250
|
+
const currentEffect = requireSameAmbiguousEffect(context, run.id, input.actionId, effect);
|
|
251
|
+
context.db.replayAmbiguousWorkflowEffect(run.id, input.actionId, `effect-replay:${input.actionId}:${input.clientId}`);
|
|
252
|
+
context.db.transitionWorkflowRun({
|
|
253
|
+
runId: run.id,
|
|
254
|
+
expectedPhase: 'blocked',
|
|
255
|
+
expectedCancellationGeneration: run.cancellationGeneration,
|
|
256
|
+
phase: run.blocked?.resumePhase,
|
|
257
|
+
blocked: null,
|
|
258
|
+
eventKey: `workflow-replay:${input.actionId}:${input.clientId}`,
|
|
259
|
+
eventType: 'workflow_ambiguous_replay_confirmed'
|
|
260
|
+
});
|
|
261
|
+
clearMatchingQuarantine(context, currentEffect, `replay:${input.clientId}`);
|
|
262
|
+
return { runId: run.id };
|
|
263
|
+
}, { runId: input.workflowId, actionId: input.actionId });
|
|
264
|
+
return { replayed: mutation.replayed, workflow: projection(context, input.workflowId) };
|
|
265
|
+
}
|
|
266
|
+
export async function complete(context, input) {
|
|
267
|
+
const replay = context.db.getIdempotentMutation('workflow_complete', input.clientId, input);
|
|
268
|
+
if (replay)
|
|
269
|
+
return { replayed: true, workflow: projection(context, replay.result.runId) };
|
|
270
|
+
heartbeat(context);
|
|
271
|
+
const mutation = context.db.idempotentMutation('workflow_complete', input.clientId, input, () => {
|
|
272
|
+
const run = requireRun(context, input.workflowId);
|
|
273
|
+
const jobs = context.db.listWorkflowJobs(run.id);
|
|
274
|
+
const outstanding = jobs.some(job => !isTerminalWorkflowJobState(job.state));
|
|
275
|
+
const implementationDelivered = jobs.some(job => job.role === 'implementation' &&
|
|
276
|
+
job.state === 'returned' &&
|
|
277
|
+
isDeliveryReceipt(job.batonReceipt) &&
|
|
278
|
+
job.batonReceipt.kind === 'message');
|
|
279
|
+
if (run.phase !== 'reviewing' ||
|
|
280
|
+
outstanding ||
|
|
281
|
+
!implementationDelivered ||
|
|
282
|
+
run.implementationBatonsDelivered < 1) {
|
|
283
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'Workflow can complete only from reviewing after a delivered implementation Baton and no outstanding job.');
|
|
284
|
+
}
|
|
285
|
+
context.db.transitionWorkflowRun({
|
|
286
|
+
runId: run.id,
|
|
287
|
+
expectedPhase: 'reviewing',
|
|
288
|
+
expectedCancellationGeneration: run.cancellationGeneration,
|
|
289
|
+
phase: 'completed',
|
|
290
|
+
eventKey: `workflow-completed:${input.clientId}`,
|
|
291
|
+
eventType: 'workflow_completed'
|
|
292
|
+
});
|
|
293
|
+
context.db.revokeWorkflowCapabilities(run.id, `revoke-on-complete:${input.clientId}`);
|
|
294
|
+
return { runId: run.id };
|
|
295
|
+
}, { runId: input.workflowId });
|
|
296
|
+
return { replayed: mutation.replayed, workflow: projection(context, input.workflowId) };
|
|
297
|
+
}
|
|
298
|
+
export async function cancel(context, input) {
|
|
299
|
+
const replay = context.db.getIdempotentMutation('workflow_cancel', input.clientId, input);
|
|
300
|
+
if (replay)
|
|
301
|
+
return { replayed: true, workflow: projection(context, replay.result.runId) };
|
|
302
|
+
heartbeat(context);
|
|
303
|
+
const mutation = context.db.idempotentMutation('workflow_cancel', input.clientId, input, () => {
|
|
304
|
+
context.db.cancelWorkflowRun(input.workflowId, `workflow-cancelled:${input.clientId}`);
|
|
305
|
+
context.db.revokeWorkflowCapabilities(input.workflowId, `revoke-on-cancel:${input.clientId}`);
|
|
306
|
+
return { runId: input.workflowId };
|
|
307
|
+
}, { runId: input.workflowId });
|
|
308
|
+
return { replayed: mutation.replayed, workflow: projection(context, input.workflowId) };
|
|
309
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { adopt, cancel, complete, delegate, replay, retry } from "./commands.js";
|
|
2
|
+
import { start } from "./start.js";
|
|
3
|
+
import { ownsSession, projection, projections, runIdsNeedingWake, workflowForSession } from "./state.js";
|
|
4
|
+
import { wake } from "./wake.js";
|
|
5
|
+
export { WorkflowCoordinatorError } from "./errors.js";
|
|
6
|
+
export { workflowEffectCorrelationMarker } from "./helpers.js";
|
|
7
|
+
export * from "./types.js";
|
|
8
|
+
/** Owns one wake map and the shared dependencies for every run lifecycle. */
|
|
9
|
+
export class WorkflowCoordinator {
|
|
10
|
+
context;
|
|
11
|
+
constructor(db, relay, deps) {
|
|
12
|
+
this.context = { db, relay, deps, waking: new Map() };
|
|
13
|
+
db.registerRelayInstance({ ...relay, canDriveUi: true });
|
|
14
|
+
}
|
|
15
|
+
start = (...args) => start(this.context, ...args);
|
|
16
|
+
wake = (...args) => wake(this.context, ...args);
|
|
17
|
+
delegate = (...args) => delegate(this.context, ...args);
|
|
18
|
+
retry = (...args) => retry(this.context, ...args);
|
|
19
|
+
adopt = (...args) => adopt(this.context, ...args);
|
|
20
|
+
replay = (...args) => replay(this.context, ...args);
|
|
21
|
+
complete = (...args) => complete(this.context, ...args);
|
|
22
|
+
cancel = (...args) => cancel(this.context, ...args);
|
|
23
|
+
projection = (...args) => projection(this.context, ...args);
|
|
24
|
+
projections = (...args) => projections(this.context, ...args);
|
|
25
|
+
runIdsNeedingWake = (...args) => runIdsNeedingWake(this.context, ...args);
|
|
26
|
+
workflowForSession = (...args) => workflowForSession(this.context, ...args);
|
|
27
|
+
ownsSession = (...args) => ownsSession(this.context, ...args);
|
|
28
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { WorkflowCoordinatorError } from "./errors.js";
|
|
2
|
+
import { cleanUnknown, isDeliveryReceipt, receiptEventSuffix } from "./helpers.js";
|
|
3
|
+
import { blockRun, effectReadCall, requireBlockedAction, requireEffect, requireRun } from "./state.js";
|
|
4
|
+
/** A later positive receipt resolves ambiguity safely; a newly visible candidate only refreshes the phone choice. */
|
|
5
|
+
export async function reconcileBlockedEffect(context, run) {
|
|
6
|
+
if (run.phase !== 'blocked' || run.blocked?.retryClass !== 'ambiguous')
|
|
7
|
+
return false;
|
|
8
|
+
const effect = context.db.getWorkflowEffect(run.id, run.blocked.actionId);
|
|
9
|
+
if (effect?.state !== 'ambiguous')
|
|
10
|
+
return false;
|
|
11
|
+
const reconciliation = await context.deps.reconcileEffect?.(effectReadCall(run, effect));
|
|
12
|
+
if (!reconciliation || reconciliation.status === 'pending')
|
|
13
|
+
return false;
|
|
14
|
+
if (reconciliation.status === 'committed') {
|
|
15
|
+
const receipt = cleanUnknown(reconciliation.receipt);
|
|
16
|
+
context.db.idempotentMutation('reconcile_blocked_workflow_effect', `${run.id}:${effect.actionId}:${receiptEventSuffix(receipt, `attempt:${effect.attemptCount}`)}`, { actionId: effect.actionId, receipt }, () => {
|
|
17
|
+
const current = requireBlockedAction(context, run.id, effect.actionId);
|
|
18
|
+
const currentEffect = requireSameAmbiguousEffect(context, current.id, effect.actionId, effect);
|
|
19
|
+
context.db.markWorkflowEffectCommitted({
|
|
20
|
+
runId: current.id,
|
|
21
|
+
actionId: effect.actionId,
|
|
22
|
+
receipt,
|
|
23
|
+
eventKey: `effect-positive-while-blocked:${effect.actionId}:${effect.attemptCount}`
|
|
24
|
+
});
|
|
25
|
+
context.db.transitionWorkflowRun({
|
|
26
|
+
runId: current.id,
|
|
27
|
+
expectedPhase: 'blocked',
|
|
28
|
+
expectedCancellationGeneration: current.cancellationGeneration,
|
|
29
|
+
phase: current.blocked?.resumePhase,
|
|
30
|
+
blocked: null,
|
|
31
|
+
eventKey: `workflow-positive-while-blocked:${effect.actionId}:${effect.attemptCount}`,
|
|
32
|
+
eventType: 'workflow_ambiguous_effect_reconciled'
|
|
33
|
+
});
|
|
34
|
+
clearMatchingQuarantine(context, currentEffect, `positive-receipt:${effect.id}`);
|
|
35
|
+
return { runId: current.id };
|
|
36
|
+
}, { runId: run.id, actionId: effect.actionId });
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
const candidates = (reconciliation.candidates ?? [])
|
|
40
|
+
.slice(0, 20)
|
|
41
|
+
.map(candidate => cleanUnknown(candidate));
|
|
42
|
+
const prior = run.blocked.candidates ?? [];
|
|
43
|
+
if (JSON.stringify(prior) === JSON.stringify(candidates))
|
|
44
|
+
return false;
|
|
45
|
+
context.db.transitionWorkflowRun({
|
|
46
|
+
runId: run.id,
|
|
47
|
+
expectedPhase: 'blocked',
|
|
48
|
+
expectedCancellationGeneration: run.cancellationGeneration,
|
|
49
|
+
phase: 'blocked',
|
|
50
|
+
blocked: { ...run.blocked, candidates },
|
|
51
|
+
eventKey: `workflow-candidates-refreshed:${effect.actionId}:${effect.attemptCount}:${candidates.map(item => item.id).join(',') || 'none'}`,
|
|
52
|
+
eventType: 'workflow_adoption_candidates_refreshed',
|
|
53
|
+
eventData: { actionId: effect.actionId, candidateCount: candidates.length }
|
|
54
|
+
});
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
export function markReceiptLost(context, run, effect, evidence) {
|
|
58
|
+
if (!isDeliveryReceipt(effect.receipt) || effect.receipt.kind !== 'outbox') {
|
|
59
|
+
throw new WorkflowCoordinatorError('workflow_adapter_invalid', `Delivery resolver reported a non-outbox receipt lost for ${effect.actionId}.`);
|
|
60
|
+
}
|
|
61
|
+
const errorMessage = 'Conductor accepted the queued message, but it disappeared before a durable message row was found.';
|
|
62
|
+
const ambiguous = effect.state === 'dispatched'
|
|
63
|
+
? context.db.markWorkflowEffectAmbiguous({
|
|
64
|
+
runId: run.id,
|
|
65
|
+
actionId: effect.actionId,
|
|
66
|
+
errorCode: 'outbox_receipt_lost',
|
|
67
|
+
errorMessage,
|
|
68
|
+
...(evidence === undefined ? {} : { evidence: cleanUnknown(evidence) }),
|
|
69
|
+
eventKey: `effect-receipt-lost:${effect.actionId}:${effect.receipt.id}`
|
|
70
|
+
})
|
|
71
|
+
: context.db.markWorkflowEffectReceiptLost({
|
|
72
|
+
runId: run.id,
|
|
73
|
+
actionId: effect.actionId,
|
|
74
|
+
expectedReceipt: effect.receipt,
|
|
75
|
+
errorCode: 'outbox_receipt_lost',
|
|
76
|
+
errorMessage,
|
|
77
|
+
...(evidence === undefined ? {} : { evidence: cleanUnknown(evidence) }),
|
|
78
|
+
eventKey: `effect-receipt-lost:${effect.actionId}:${effect.receipt.id}`
|
|
79
|
+
});
|
|
80
|
+
const current = requireRun(context, run.id);
|
|
81
|
+
if (current.phase === 'cancelled') {
|
|
82
|
+
context.db.activateUiQuarantine({
|
|
83
|
+
actionId: ambiguous.actionId,
|
|
84
|
+
effectId: ambiguous.id,
|
|
85
|
+
reason: ambiguous.errorMessage ?? 'A cancelled Workflow has a lost accepted outbox receipt.',
|
|
86
|
+
owner: ambiguous.owner,
|
|
87
|
+
externalProcess: ambiguous.externalProcess
|
|
88
|
+
});
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
quarantineAndBlock(context, current, ambiguous);
|
|
92
|
+
}
|
|
93
|
+
export function quarantineAndBlock(context, run, effect, candidates) {
|
|
94
|
+
context.db.activateUiQuarantine({
|
|
95
|
+
actionId: effect.actionId,
|
|
96
|
+
effectId: effect.id,
|
|
97
|
+
reason: effect.errorMessage ?? 'Workflow UI effect has no positive receipt.',
|
|
98
|
+
owner: effect.owner,
|
|
99
|
+
externalProcess: effect.externalProcess
|
|
100
|
+
});
|
|
101
|
+
blockRun(context, requireRun(context, run.id), {
|
|
102
|
+
actionId: effect.actionId,
|
|
103
|
+
errorCode: effect.errorCode ?? 'workflow_effect_ambiguous',
|
|
104
|
+
message: effect.errorMessage ?? 'The UI action may have executed; automatic replay is disabled.',
|
|
105
|
+
retryClass: 'ambiguous',
|
|
106
|
+
candidates
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** Call only inside the surrounding durable mutation so a newer hold cannot be cleared between read and write. */
|
|
110
|
+
export function clearMatchingQuarantine(context, effect, clearedBy) {
|
|
111
|
+
const quarantine = context.db.getUiQuarantine();
|
|
112
|
+
const matches = quarantine.effectId
|
|
113
|
+
? quarantine.effectId === effect.id
|
|
114
|
+
: quarantine.actionId === effect.id || quarantine.actionId === effect.actionId;
|
|
115
|
+
if (quarantine.active && matches) {
|
|
116
|
+
context.db.clearUiQuarantine(clearedBy);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export function requireSameAmbiguousEffect(context, runId, actionId, expected) {
|
|
120
|
+
const current = requireEffect(context, runId, actionId);
|
|
121
|
+
if (current.state !== 'ambiguous' || current.attemptCount !== expected.attemptCount) {
|
|
122
|
+
throw new WorkflowCoordinatorError('workflow_recovery_invalid', 'The ambiguous action changed while it was being validated; inspect its current state and try again.');
|
|
123
|
+
}
|
|
124
|
+
return current;
|
|
125
|
+
}
|