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,159 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { and, asc, eq, notInArray } from 'drizzle-orm';
|
|
3
|
+
import { isTerminalWorkflowJobState, isTerminalWorkflowPhase } from "../workflow/machine.js";
|
|
4
|
+
import { decodeJob } from "./codecs.js";
|
|
5
|
+
import { WorkflowTransitionError } from "./errors.js";
|
|
6
|
+
import { appendEvent, touchRun } from "./events.js";
|
|
7
|
+
import { canonicalRequestJson } from "./idempotency.js";
|
|
8
|
+
import { checkRunGuard, requireJob, requireRun } from "./records.js";
|
|
9
|
+
import { workflowJobs, workflowRuns } from "./schema.js";
|
|
10
|
+
import { asObject, json, optionalJson, ownerAuditKey, probeAlive, RUNNABLE_PHASES, sameOwner, validateRelayIdentity } from "./values.js";
|
|
11
|
+
export function insertJob(context, input) {
|
|
12
|
+
context.db
|
|
13
|
+
.prepare(`INSERT INTO workflow_jobs (
|
|
14
|
+
id, run_id, logical_key, role, cycle, revision, resolved_role_json, prompt, state, cancellation_generation,
|
|
15
|
+
transcript_cursor_json, created_at, updated_at
|
|
16
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
17
|
+
.run(input.id, input.runId, input.logicalKey, input.role, input.cycle, input.revision, json(input.resolvedRole), input.prompt, input.state, input.cancellationGeneration, optionalJson(input.transcriptCursor), input.at, input.at);
|
|
18
|
+
}
|
|
19
|
+
export function createWorkflowJob(context, input) {
|
|
20
|
+
return context.immediate(() => {
|
|
21
|
+
const run = requireRun(context, input.runId);
|
|
22
|
+
checkRunGuard(run, RUNNABLE_PHASES, input.expectedCancellationGeneration);
|
|
23
|
+
const existing = context.orm
|
|
24
|
+
.select()
|
|
25
|
+
.from(workflowJobs)
|
|
26
|
+
.where(and(eq(workflowJobs.runId, input.runId), eq(workflowJobs.logicalKey, input.logicalKey)))
|
|
27
|
+
.get();
|
|
28
|
+
if (existing) {
|
|
29
|
+
const decoded = decodeJob(existing);
|
|
30
|
+
if (decoded.role !== input.role ||
|
|
31
|
+
decoded.cycle !== (input.cycle ?? run.cycle) ||
|
|
32
|
+
decoded.revision !== (input.revision ?? run.revision) ||
|
|
33
|
+
canonicalRequestJson(decoded.resolvedRole) !== canonicalRequestJson(input.resolvedRole) ||
|
|
34
|
+
decoded.prompt !== input.prompt) {
|
|
35
|
+
throw new WorkflowTransitionError(`logical job ${input.logicalKey} already has different frozen inputs`);
|
|
36
|
+
}
|
|
37
|
+
return { created: false, job: decoded };
|
|
38
|
+
}
|
|
39
|
+
const id = input.id ?? randomUUID();
|
|
40
|
+
const at = context.now();
|
|
41
|
+
insertJob(context, {
|
|
42
|
+
...input,
|
|
43
|
+
id,
|
|
44
|
+
cycle: input.cycle ?? run.cycle,
|
|
45
|
+
revision: input.revision ?? run.revision,
|
|
46
|
+
state: input.state ?? 'queued',
|
|
47
|
+
cancellationGeneration: run.cancellationGeneration,
|
|
48
|
+
at
|
|
49
|
+
});
|
|
50
|
+
touchRun(context, input.runId, at);
|
|
51
|
+
appendEvent(context, input.runId, input.eventKey, 'workflow_job_created', {
|
|
52
|
+
jobId: id,
|
|
53
|
+
logicalKey: input.logicalKey,
|
|
54
|
+
role: input.role,
|
|
55
|
+
cycle: input.cycle ?? run.cycle,
|
|
56
|
+
revision: input.revision ?? run.revision,
|
|
57
|
+
state: input.state ?? 'queued'
|
|
58
|
+
});
|
|
59
|
+
return { created: true, job: requireJob(context, id) };
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export function activateWorkflowJob(context, jobId, expectedCancellationGeneration, eventKey, transcriptCursor) {
|
|
63
|
+
return updateWorkflowJob(context, {
|
|
64
|
+
jobId,
|
|
65
|
+
expectedStates: ['dormant'],
|
|
66
|
+
expectedCancellationGeneration,
|
|
67
|
+
state: 'queued',
|
|
68
|
+
...(transcriptCursor === undefined ? {} : { transcriptCursor }),
|
|
69
|
+
eventKey,
|
|
70
|
+
eventType: 'workflow_job_activated'
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
export function claimNextWorkflowJob(context, owner, runId) {
|
|
74
|
+
validateRelayIdentity(owner);
|
|
75
|
+
return context.immediate(() => {
|
|
76
|
+
const row = context.orm
|
|
77
|
+
.select({ job: workflowJobs })
|
|
78
|
+
.from(workflowJobs)
|
|
79
|
+
.innerJoin(workflowRuns, eq(workflowRuns.id, workflowJobs.runId))
|
|
80
|
+
.where(and(eq(workflowJobs.state, 'queued'), eq(workflowJobs.cancellationGeneration, workflowRuns.cancellationGeneration), notInArray(workflowRuns.phase, ['blocked', 'completed', 'cancelled']), runId === undefined ? undefined : eq(workflowJobs.runId, runId)))
|
|
81
|
+
.orderBy(asc(workflowJobs.createdAt), asc(workflowJobs.id))
|
|
82
|
+
.limit(1)
|
|
83
|
+
.get()?.job;
|
|
84
|
+
if (!row)
|
|
85
|
+
return undefined;
|
|
86
|
+
const at = context.now();
|
|
87
|
+
const result = context.db
|
|
88
|
+
.prepare(`UPDATE workflow_jobs SET state = 'owned', owner_instance_id = ?, owner_pid = ?,
|
|
89
|
+
owner_process_started_at = ?, owner_protocol_version = ?, updated_at = ?
|
|
90
|
+
WHERE id = ? AND state = 'queued'`)
|
|
91
|
+
.run(owner.instanceId, owner.pid, owner.processStartedAt, owner.protocolVersion, at, row.id);
|
|
92
|
+
if (Number(result.changes) !== 1)
|
|
93
|
+
return undefined;
|
|
94
|
+
touchRun(context, row.runId, at);
|
|
95
|
+
appendEvent(context, row.runId, `job_claimed:${row.id}:${row.attemptCount + 1}:${ownerAuditKey(owner)}`, 'workflow_job_claimed', {
|
|
96
|
+
jobId: row.id,
|
|
97
|
+
ownerInstanceId: owner.instanceId
|
|
98
|
+
});
|
|
99
|
+
return requireJob(context, row.id);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
/** Requeue only the pre-effect `owned` state after exact process death is proven. */
|
|
103
|
+
export function reconcileAbandonedWorkflowJobClaim(context, input) {
|
|
104
|
+
const observed = requireJob(context, input.jobId);
|
|
105
|
+
if (!observed.owner || observed.state !== 'owned')
|
|
106
|
+
return { status: 'unsafe', job: observed };
|
|
107
|
+
const observedOwner = observed.owner;
|
|
108
|
+
const probe = input.processProbe ?? context.processProbe;
|
|
109
|
+
if (probeAlive(observedOwner, probe))
|
|
110
|
+
return { status: 'owner_alive', job: observed };
|
|
111
|
+
return context.immediate(() => {
|
|
112
|
+
const current = requireJob(context, input.jobId);
|
|
113
|
+
if (current.state !== 'owned' ||
|
|
114
|
+
!current.owner ||
|
|
115
|
+
!sameOwner(current.owner, observedOwner) ||
|
|
116
|
+
current.attemptCount !== observed.attemptCount) {
|
|
117
|
+
return { status: 'changed', job: current };
|
|
118
|
+
}
|
|
119
|
+
const at = context.now();
|
|
120
|
+
context.db
|
|
121
|
+
.prepare(`UPDATE workflow_jobs SET state = 'queued', owner_instance_id = NULL, owner_pid = NULL,
|
|
122
|
+
owner_process_started_at = NULL, owner_protocol_version = NULL, updated_at = ? WHERE id = ?`)
|
|
123
|
+
.run(at, current.id);
|
|
124
|
+
touchRun(context, current.runId, at);
|
|
125
|
+
appendEvent(context, current.runId, `${input.eventKey}:${ownerAuditKey(observedOwner)}`, 'workflow_job_claim_recovered', {
|
|
126
|
+
jobId: current.id,
|
|
127
|
+
abandonedInstanceId: observedOwner.instanceId
|
|
128
|
+
});
|
|
129
|
+
return { status: 'requeued', job: requireJob(context, current.id) };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
export function updateWorkflowJob(context, input) {
|
|
133
|
+
return context.immediate(() => {
|
|
134
|
+
const job = requireJob(context, input.jobId);
|
|
135
|
+
const run = requireRun(context, job.runId);
|
|
136
|
+
if (isTerminalWorkflowPhase(run.phase) || job.state === 'cancelled') {
|
|
137
|
+
throw new WorkflowTransitionError(`cannot advance terminal job ${job.id}`);
|
|
138
|
+
}
|
|
139
|
+
checkRunGuard(run, undefined, input.expectedCancellationGeneration);
|
|
140
|
+
if (!input.expectedStates.includes(job.state)) {
|
|
141
|
+
throw new WorkflowTransitionError(`job ${job.id} is ${job.state}, expected ${input.expectedStates.join(' or ')}`);
|
|
142
|
+
}
|
|
143
|
+
const at = context.now();
|
|
144
|
+
const terminalAt = isTerminalWorkflowJobState(input.state) ? (job.terminalAt ?? at) : null;
|
|
145
|
+
const result = context.db
|
|
146
|
+
.prepare(`UPDATE workflow_jobs SET state = ?, transcript_cursor_json = ?, child_session_id = ?, outcome_json = ?, task_receipt_json = ?,
|
|
147
|
+
baton_receipt_json = ?, owner_instance_id = ?, owner_pid = ?, owner_process_started_at = ?,
|
|
148
|
+
owner_protocol_version = ?, updated_at = ?, terminal_at = ?
|
|
149
|
+
WHERE id = ? AND state = ? AND cancellation_generation = ?`)
|
|
150
|
+
.run(input.state, input.transcriptCursor === undefined
|
|
151
|
+
? optionalJson(job.transcriptCursor)
|
|
152
|
+
: optionalJson(input.transcriptCursor), input.childSessionId === undefined ? (job.childSessionId ?? null) : input.childSessionId, input.outcome === undefined ? optionalJson(job.outcome) : optionalJson(input.outcome), input.taskReceipt === undefined ? optionalJson(job.taskReceipt) : optionalJson(input.taskReceipt), input.batonReceipt === undefined ? optionalJson(job.batonReceipt) : optionalJson(input.batonReceipt), input.clearOwner ? null : (job.owner?.instanceId ?? null), input.clearOwner ? null : (job.owner?.pid ?? null), input.clearOwner ? null : (job.owner?.processStartedAt ?? null), input.clearOwner ? null : (job.owner?.protocolVersion ?? null), at, terminalAt, job.id, job.state, input.expectedCancellationGeneration);
|
|
153
|
+
if (Number(result.changes) !== 1)
|
|
154
|
+
throw new WorkflowTransitionError(`job ${job.id} changed concurrently`);
|
|
155
|
+
touchRun(context, job.runId, at);
|
|
156
|
+
appendEvent(context, job.runId, input.eventKey, input.eventType, { jobId: job.id, ...asObject(input.eventData) });
|
|
157
|
+
return requireJob(context, job.id);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { desc, inArray, notInArray } from 'drizzle-orm';
|
|
2
|
+
import { isTerminalWorkflowJobState, isTerminalWorkflowPhase } from "../workflow/machine.js";
|
|
3
|
+
import { decodeRun } from "./codecs.js";
|
|
4
|
+
import { appendEvent } from "./events.js";
|
|
5
|
+
import { getWorkflowEffect, getWorkflowRun, listWorkflowJobs } from "./records.js";
|
|
6
|
+
import { workflowRuns } from "./schema.js";
|
|
7
|
+
import { ALL_JOB_STATES } from "./values.js";
|
|
8
|
+
export function getWorkflowProjection(context, runId) {
|
|
9
|
+
const run = getWorkflowRun(context, runId);
|
|
10
|
+
return run ? projectRun(context, run) : undefined;
|
|
11
|
+
}
|
|
12
|
+
export function listWorkflowProjections(context, options = {}) {
|
|
13
|
+
return context.orm
|
|
14
|
+
.select()
|
|
15
|
+
.from(workflowRuns)
|
|
16
|
+
.where(options.includeTerminal ? undefined : notInArray(workflowRuns.phase, ['completed', 'cancelled']))
|
|
17
|
+
.orderBy(desc(workflowRuns.updatedAt))
|
|
18
|
+
.all()
|
|
19
|
+
.map(row => projectRun(context, decodeRun(row)));
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The sidebar keeps the identity of the newest Workflow that touched each live
|
|
23
|
+
* workspace after that run reaches a terminal phase. Limit the query to the
|
|
24
|
+
* workspaces on screen and project only one run per workspace: terminal history
|
|
25
|
+
* is unbounded, while `/api/state` is polled every few seconds.
|
|
26
|
+
*/
|
|
27
|
+
export function listLatestWorkflowProjectionsForWorkspaces(context, workspaceIds) {
|
|
28
|
+
const ids = [...new Set(workspaceIds.filter(Boolean))];
|
|
29
|
+
if (!ids.length)
|
|
30
|
+
return [];
|
|
31
|
+
const placeholders = ids.map(() => '?').join(', ');
|
|
32
|
+
const newest = context.db
|
|
33
|
+
.prepare(`SELECT id FROM (
|
|
34
|
+
SELECT id, created_at, updated_at,
|
|
35
|
+
ROW_NUMBER() OVER (
|
|
36
|
+
PARTITION BY workspace_id
|
|
37
|
+
ORDER BY created_at DESC, updated_at DESC, id DESC
|
|
38
|
+
) AS ordinal
|
|
39
|
+
FROM workflow_runs
|
|
40
|
+
WHERE workspace_id IN (${placeholders})
|
|
41
|
+
) WHERE ordinal = 1
|
|
42
|
+
ORDER BY created_at DESC, updated_at DESC, id DESC`)
|
|
43
|
+
.all(...ids);
|
|
44
|
+
if (!newest.length)
|
|
45
|
+
return [];
|
|
46
|
+
const rows = context.orm
|
|
47
|
+
.select()
|
|
48
|
+
.from(workflowRuns)
|
|
49
|
+
.where(inArray(workflowRuns.id, newest.map(row => row.id)))
|
|
50
|
+
.all();
|
|
51
|
+
const byId = new Map(rows.map(row => [row.id, row]));
|
|
52
|
+
return newest.flatMap(({ id }) => {
|
|
53
|
+
const row = byId.get(id);
|
|
54
|
+
return row ? [projectRun(context, decodeRun(row))] : [];
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Active runs always need driving. A cancelled run remains wakeable only while
|
|
59
|
+
* it has durable evidence that can still settle: an external effect whose
|
|
60
|
+
* outcome is unknown, an accepted outbox receipt awaiting promotion, or a
|
|
61
|
+
* delivered child turn whose final outcome has not appeared yet.
|
|
62
|
+
*/
|
|
63
|
+
export function listWorkflowRunIdsNeedingWake(context) {
|
|
64
|
+
return context.db
|
|
65
|
+
.prepare(`SELECT r.id FROM workflow_runs r
|
|
66
|
+
WHERE r.phase NOT IN ('completed', 'cancelled')
|
|
67
|
+
OR (
|
|
68
|
+
r.phase = 'cancelled' AND (
|
|
69
|
+
EXISTS (
|
|
70
|
+
SELECT 1 FROM workflow_effects e
|
|
71
|
+
WHERE e.run_id = r.id AND (
|
|
72
|
+
e.state IN ('dispatched', 'ambiguous') OR
|
|
73
|
+
(e.state = 'committed' AND json_extract(e.receipt_json, '$.kind') = 'outbox')
|
|
74
|
+
)
|
|
75
|
+
) OR EXISTS (
|
|
76
|
+
SELECT 1 FROM workflow_jobs j
|
|
77
|
+
WHERE j.run_id = r.id AND j.state = 'cancelled'
|
|
78
|
+
AND j.child_session_id IS NOT NULL AND j.outcome_json IS NULL
|
|
79
|
+
AND (
|
|
80
|
+
json_extract(j.task_receipt_json, '$.kind') = 'message' OR EXISTS (
|
|
81
|
+
SELECT 1 FROM workflow_effects task
|
|
82
|
+
WHERE task.job_id = j.id AND task.kind = 'send_task'
|
|
83
|
+
AND task.state = 'committed'
|
|
84
|
+
AND json_extract(task.receipt_json, '$.kind') = 'message'
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
ORDER BY r.updated_at DESC, r.id`)
|
|
91
|
+
.all().map(row => row.id);
|
|
92
|
+
}
|
|
93
|
+
export function projectRun(context, run) {
|
|
94
|
+
const jobs = listWorkflowJobs(context, run.id);
|
|
95
|
+
const summary = (role) => {
|
|
96
|
+
const selected = jobs.filter(job => job.role === role && job.state !== 'cancelled');
|
|
97
|
+
return {
|
|
98
|
+
requested: selected.length,
|
|
99
|
+
running: selected.filter(job => !isTerminalWorkflowJobState(job.state) && job.state !== 'dormant').length,
|
|
100
|
+
returned: selected.filter(job => job.state === 'returned').length,
|
|
101
|
+
failed: selected.filter(job => job.state === 'failed').length
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
const publicRole = (role) => ({
|
|
105
|
+
agentType: role.agentType,
|
|
106
|
+
model: role.model,
|
|
107
|
+
...(role.effort === undefined ? {} : { effort: role.effort }),
|
|
108
|
+
...(role.fast === undefined ? {} : { fast: role.fast })
|
|
109
|
+
});
|
|
110
|
+
const blocked = run.blocked;
|
|
111
|
+
const candidates = (blocked?.candidates ?? []).slice(0, 20).map(candidate => ({
|
|
112
|
+
id: candidate.id,
|
|
113
|
+
title: context.scrubPublicText(candidate.title).slice(0, 200),
|
|
114
|
+
repo: context.scrubPublicText(candidate.repo).slice(0, 200),
|
|
115
|
+
createdAt: candidate.createdAt
|
|
116
|
+
}));
|
|
117
|
+
const blockedEffect = blocked ? getWorkflowEffect(context, run.id, blocked.actionId) : undefined;
|
|
118
|
+
const adoptionKind = blocked?.candidates?.[0]?.kind ?? (blockedEffect?.kind === 'create_workspace' ? 'workspace' : 'session');
|
|
119
|
+
// Risky replay still needs the stable action id when reconciliation found no
|
|
120
|
+
// candidate. The fixed wire deliberately has no second public effect-id field,
|
|
121
|
+
// so retain the recovery envelope with an empty candidates array.
|
|
122
|
+
const exposeRecoveryAction = !!blocked &&
|
|
123
|
+
(candidates.length > 0 || blocked.retryClass === 'ambiguous') &&
|
|
124
|
+
(adoptionKind === 'workspace' || adoptionKind === 'session');
|
|
125
|
+
const outstanding = jobs.some(job => !isTerminalWorkflowJobState(job.state));
|
|
126
|
+
const implementationReturned = jobs.some(job => job.role === 'implementation' && job.state === 'returned');
|
|
127
|
+
return {
|
|
128
|
+
id: run.id,
|
|
129
|
+
...(run.workspaceId ? { workspaceId: run.workspaceId } : {}),
|
|
130
|
+
...(run.rootSessionId ? { rootSessionId: run.rootSessionId } : {}),
|
|
131
|
+
phase: run.phase,
|
|
132
|
+
objectiveExcerpt: context.scrubPublicText(run.objective).slice(0, 240),
|
|
133
|
+
roles: {
|
|
134
|
+
planning: publicRole(run.roles.planning),
|
|
135
|
+
exploration: publicRole(run.roles.exploration),
|
|
136
|
+
implementation: publicRole(run.roles.implementation)
|
|
137
|
+
},
|
|
138
|
+
jobs: { exploration: summary('exploration'), implementation: summary('implementation') },
|
|
139
|
+
...(blocked
|
|
140
|
+
? {
|
|
141
|
+
error: {
|
|
142
|
+
code: blocked.errorCode,
|
|
143
|
+
message: context.scrubPublicText(blocked.message).slice(0, 500),
|
|
144
|
+
retryable: blocked.retryClass === 'deterministic'
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
: {}),
|
|
148
|
+
...(exposeRecoveryAction ? { adoption: { actionId: blocked.actionId, kind: adoptionKind, candidates } } : {}),
|
|
149
|
+
actions: {
|
|
150
|
+
canRetry: run.phase === 'blocked' && run.blocked?.retryClass === 'deterministic',
|
|
151
|
+
canAdopt: run.phase === 'blocked' && candidates.length > 0,
|
|
152
|
+
canReplayAmbiguous: run.phase === 'blocked' && run.blocked?.retryClass === 'ambiguous',
|
|
153
|
+
canCancel: !isTerminalWorkflowPhase(run.phase),
|
|
154
|
+
canComplete: run.phase === 'reviewing' && implementationReturned && !outstanding
|
|
155
|
+
},
|
|
156
|
+
createdAt: run.createdAt,
|
|
157
|
+
updatedAt: run.updatedAt
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
export function compactTerminalRuns(context, options) {
|
|
161
|
+
return context.immediate(() => {
|
|
162
|
+
const rows = context.db
|
|
163
|
+
.prepare(`SELECT id FROM workflow_runs
|
|
164
|
+
WHERE phase IN ('completed', 'cancelled') AND terminal_at IS NOT NULL AND terminal_at < ?
|
|
165
|
+
AND NOT EXISTS (
|
|
166
|
+
SELECT 1 FROM workflow_events e
|
|
167
|
+
WHERE e.run_id = workflow_runs.id AND e.type = 'terminal_compacted'
|
|
168
|
+
)
|
|
169
|
+
ORDER BY terminal_at LIMIT ?`)
|
|
170
|
+
.all(options.olderThan, Math.max(1, Math.min(options.limit ?? 20, 100)));
|
|
171
|
+
for (const row of rows) {
|
|
172
|
+
const jobs = listWorkflowJobs(context, row.id);
|
|
173
|
+
const effects = context.db
|
|
174
|
+
.prepare('SELECT state, COUNT(*) count FROM workflow_effects WHERE run_id = ? GROUP BY state')
|
|
175
|
+
.all(row.id);
|
|
176
|
+
context.db.prepare('DELETE FROM workflow_capabilities WHERE run_id = ?').run(row.id);
|
|
177
|
+
context.db.prepare('DELETE FROM workflow_jobs WHERE run_id = ?').run(row.id);
|
|
178
|
+
context.db.prepare('DELETE FROM workflow_effects WHERE run_id = ?').run(row.id);
|
|
179
|
+
context.db.prepare('DELETE FROM workflow_events WHERE run_id = ?').run(row.id);
|
|
180
|
+
appendEvent(context, row.id, 'terminal_compacted', 'terminal_compacted', {
|
|
181
|
+
jobs: Object.fromEntries(['exploration', 'implementation'].map(role => [
|
|
182
|
+
role,
|
|
183
|
+
Object.fromEntries(ALL_JOB_STATES.map(state => [state, jobs.filter(job => job.role === role && job.state === state).length]))
|
|
184
|
+
])),
|
|
185
|
+
effects: Object.fromEntries(effects.map(effect => [effect.state, effect.count]))
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return rows.length;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { OrchestrationError } from "./errors.js";
|
|
3
|
+
import { uiQuarantine, uiQuarantineSelectSchema } from "./schema.js";
|
|
4
|
+
import { externalFromColumns, nonEmpty, ownerFromColumns } from "./values.js";
|
|
5
|
+
export function activateUiQuarantine(context, input) {
|
|
6
|
+
return context.immediate(() => {
|
|
7
|
+
const current = getUiQuarantine(context);
|
|
8
|
+
if (current.active)
|
|
9
|
+
return current;
|
|
10
|
+
const at = context.now();
|
|
11
|
+
context.db
|
|
12
|
+
.prepare(`UPDATE ui_quarantine SET active = 1, action_id = ?, effect_id = ?, reason = ?,
|
|
13
|
+
owner_instance_id = ?, owner_pid = ?, owner_process_started_at = ?, owner_protocol_version = ?,
|
|
14
|
+
external_pid = ?, external_process_started_at = ?, external_process_group = ?,
|
|
15
|
+
created_at = ?, cleared_at = NULL, cleared_by = NULL WHERE id = 1`)
|
|
16
|
+
.run(input.actionId, input.effectId ?? null, nonEmpty(input.reason, 'quarantine reason'), input.owner?.instanceId ?? null, input.owner?.pid ?? null, input.owner?.processStartedAt ?? null, input.owner?.protocolVersion ?? null, input.externalProcess?.pid ?? null, input.externalProcess?.processStartedAt ?? null, input.externalProcess?.processGroup ?? null, at);
|
|
17
|
+
return getUiQuarantine(context);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export function clearUiQuarantine(context, clearedBy) {
|
|
21
|
+
return context.immediate(() => {
|
|
22
|
+
const result = context.db
|
|
23
|
+
.prepare('UPDATE ui_quarantine SET active = 0, cleared_at = ?, cleared_by = ? WHERE id = 1 AND active = 1')
|
|
24
|
+
.run(context.now(), nonEmpty(clearedBy, 'quarantine clearer'));
|
|
25
|
+
return Number(result.changes) === 1;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
export function getUiQuarantine(context) {
|
|
29
|
+
const candidate = context.orm.select().from(uiQuarantine).where(eq(uiQuarantine.id, 1)).get();
|
|
30
|
+
if (!candidate)
|
|
31
|
+
throw new OrchestrationError('orchestration UI quarantine singleton is missing');
|
|
32
|
+
const row = uiQuarantineSelectSchema.parse(candidate);
|
|
33
|
+
const owner = ownerFromColumns(row);
|
|
34
|
+
const externalProcess = externalFromColumns(row);
|
|
35
|
+
return {
|
|
36
|
+
active: row.active,
|
|
37
|
+
...(row.actionId === null ? {} : { actionId: row.actionId }),
|
|
38
|
+
...(row.effectId === null ? {} : { effectId: row.effectId }),
|
|
39
|
+
...(row.reason === null ? {} : { reason: row.reason }),
|
|
40
|
+
...(owner ? { owner } : {}),
|
|
41
|
+
...(externalProcess ? { externalProcess } : {}),
|
|
42
|
+
...(row.createdAt === null ? {} : { createdAt: row.createdAt }),
|
|
43
|
+
...(row.clearedAt === null ? {} : { clearedAt: row.clearedAt }),
|
|
44
|
+
...(row.clearedBy === null ? {} : { clearedBy: row.clearedBy })
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { and, asc, eq, notInArray } from 'drizzle-orm';
|
|
2
|
+
import { decodeEffect, decodeEffectAttempt, decodeJob, decodeJobAttempt, decodeRun } from "./codecs.js";
|
|
3
|
+
import { WorkflowTransitionError } from "./errors.js";
|
|
4
|
+
import { workflowEffectAttempts, workflowEffects, workflowJobAttempts, workflowJobs, workflowRuns } from "./schema.js";
|
|
5
|
+
import { sameOwner } from "./values.js";
|
|
6
|
+
export function getWorkflowRun(context, id) {
|
|
7
|
+
const row = context.orm.select().from(workflowRuns).where(eq(workflowRuns.id, id)).get();
|
|
8
|
+
return row ? decodeRun(row) : undefined;
|
|
9
|
+
}
|
|
10
|
+
export function findActiveWorkflowByRoot(context, rootSessionId) {
|
|
11
|
+
const row = context.orm
|
|
12
|
+
.select()
|
|
13
|
+
.from(workflowRuns)
|
|
14
|
+
.where(and(eq(workflowRuns.rootSessionId, rootSessionId), notInArray(workflowRuns.phase, ['completed', 'cancelled'])))
|
|
15
|
+
.get();
|
|
16
|
+
return row ? decodeRun(row) : undefined;
|
|
17
|
+
}
|
|
18
|
+
export function requireRun(context, id) {
|
|
19
|
+
const run = getWorkflowRun(context, id);
|
|
20
|
+
if (!run)
|
|
21
|
+
throw new WorkflowTransitionError(`Workflow ${id} does not exist`);
|
|
22
|
+
return run;
|
|
23
|
+
}
|
|
24
|
+
export function checkRunGuard(run, expectedPhase, expectedCancellationGeneration) {
|
|
25
|
+
const phases = Array.isArray(expectedPhase) ? expectedPhase : expectedPhase ? [expectedPhase] : undefined;
|
|
26
|
+
if (phases && !phases.includes(run.phase)) {
|
|
27
|
+
throw new WorkflowTransitionError(`Workflow ${run.id} is ${run.phase}, expected ${phases.join(' or ')}`);
|
|
28
|
+
}
|
|
29
|
+
if (run.cancellationGeneration !== expectedCancellationGeneration) {
|
|
30
|
+
throw new WorkflowTransitionError(`Workflow ${run.id} cancellation generation changed`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export function getWorkflowJob(context, id) {
|
|
34
|
+
const row = context.orm.select().from(workflowJobs).where(eq(workflowJobs.id, id)).get();
|
|
35
|
+
return row ? decodeJob(row) : undefined;
|
|
36
|
+
}
|
|
37
|
+
export function listWorkflowJobs(context, runId) {
|
|
38
|
+
return context.orm
|
|
39
|
+
.select()
|
|
40
|
+
.from(workflowJobs)
|
|
41
|
+
.where(eq(workflowJobs.runId, runId))
|
|
42
|
+
.orderBy(asc(workflowJobs.createdAt), asc(workflowJobs.id))
|
|
43
|
+
.all()
|
|
44
|
+
.map(row => decodeJob(row));
|
|
45
|
+
}
|
|
46
|
+
export function listWorkflowJobAttempts(context, jobId) {
|
|
47
|
+
return context.orm
|
|
48
|
+
.select()
|
|
49
|
+
.from(workflowJobAttempts)
|
|
50
|
+
.where(eq(workflowJobAttempts.jobId, jobId))
|
|
51
|
+
.orderBy(asc(workflowJobAttempts.attemptNumber))
|
|
52
|
+
.all()
|
|
53
|
+
.map(row => decodeJobAttempt(row));
|
|
54
|
+
}
|
|
55
|
+
export function requireJob(context, id) {
|
|
56
|
+
const job = getWorkflowJob(context, id);
|
|
57
|
+
if (!job)
|
|
58
|
+
throw new WorkflowTransitionError(`Workflow job ${id} does not exist`);
|
|
59
|
+
return job;
|
|
60
|
+
}
|
|
61
|
+
export function requireJobAttempt(context, id) {
|
|
62
|
+
const row = context.orm.select().from(workflowJobAttempts).where(eq(workflowJobAttempts.id, id)).get();
|
|
63
|
+
if (!row)
|
|
64
|
+
throw new WorkflowTransitionError(`Workflow job attempt ${id} does not exist`);
|
|
65
|
+
return decodeJobAttempt(row);
|
|
66
|
+
}
|
|
67
|
+
export function getWorkflowEffect(context, runId, actionId) {
|
|
68
|
+
const row = context.orm
|
|
69
|
+
.select()
|
|
70
|
+
.from(workflowEffects)
|
|
71
|
+
.where(and(eq(workflowEffects.runId, runId), eq(workflowEffects.actionId, actionId)))
|
|
72
|
+
.get();
|
|
73
|
+
return row ? decodeEffect(row) : undefined;
|
|
74
|
+
}
|
|
75
|
+
export function listWorkflowEffects(context, runId) {
|
|
76
|
+
return context.orm
|
|
77
|
+
.select()
|
|
78
|
+
.from(workflowEffects)
|
|
79
|
+
.where(eq(workflowEffects.runId, runId))
|
|
80
|
+
.orderBy(asc(workflowEffects.createdAt), asc(workflowEffects.id))
|
|
81
|
+
.all()
|
|
82
|
+
.map(row => decodeEffect(row));
|
|
83
|
+
}
|
|
84
|
+
export function requireEffectByAction(context, runId, actionId) {
|
|
85
|
+
const effect = getWorkflowEffect(context, runId, actionId);
|
|
86
|
+
if (!effect)
|
|
87
|
+
throw new WorkflowTransitionError(`Workflow effect ${actionId} does not exist`);
|
|
88
|
+
return effect;
|
|
89
|
+
}
|
|
90
|
+
export function requireEffect(context, id) {
|
|
91
|
+
const row = context.orm.select().from(workflowEffects).where(eq(workflowEffects.id, id)).get();
|
|
92
|
+
if (!row)
|
|
93
|
+
throw new WorkflowTransitionError(`Workflow effect ${id} does not exist`);
|
|
94
|
+
return decodeEffect(row);
|
|
95
|
+
}
|
|
96
|
+
export function requireEffectAttempt(context, id) {
|
|
97
|
+
const row = context.orm.select().from(workflowEffectAttempts).where(eq(workflowEffectAttempts.id, id)).get();
|
|
98
|
+
if (!row)
|
|
99
|
+
throw new WorkflowTransitionError(`Workflow effect attempt ${id} does not exist`);
|
|
100
|
+
return decodeEffectAttempt(row);
|
|
101
|
+
}
|
|
102
|
+
export function listWorkflowEffectAttempts(context, effectId) {
|
|
103
|
+
return context.orm
|
|
104
|
+
.select()
|
|
105
|
+
.from(workflowEffectAttempts)
|
|
106
|
+
.where(eq(workflowEffectAttempts.effectId, effectId))
|
|
107
|
+
.orderBy(asc(workflowEffectAttempts.attemptNumber))
|
|
108
|
+
.all()
|
|
109
|
+
.map(row => decodeEffectAttempt(row));
|
|
110
|
+
}
|
|
111
|
+
export function requireEffectOwner(effect, owner, attemptNumber) {
|
|
112
|
+
if (!effect.owner || !sameOwner(effect.owner, owner) || effect.attemptCount !== attemptNumber) {
|
|
113
|
+
throw new WorkflowTransitionError(`effect ${effect.actionId} is not owned by this relay attempt`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { asc } from 'drizzle-orm';
|
|
2
|
+
import { OrchestrationError } from "./errors.js";
|
|
3
|
+
import { relayInstanceSelectSchema, relayInstances } from "./schema.js";
|
|
4
|
+
import { jsonProperty, optionalJson, probeAlive, sameOwner, validateRelayIdentity } from "./values.js";
|
|
5
|
+
export function registerRelayInstance(context, input) {
|
|
6
|
+
validateRelayIdentity(input);
|
|
7
|
+
return context.immediate(() => {
|
|
8
|
+
const at = context.now();
|
|
9
|
+
context.db
|
|
10
|
+
.prepare(`INSERT INTO relay_instances (
|
|
11
|
+
instance_id, pid, process_started_at, protocol_version, can_drive_ui,
|
|
12
|
+
heartbeat_at, registered_at, metadata_json
|
|
13
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
14
|
+
ON CONFLICT(instance_id) DO UPDATE SET
|
|
15
|
+
pid = excluded.pid,
|
|
16
|
+
process_started_at = excluded.process_started_at,
|
|
17
|
+
protocol_version = excluded.protocol_version,
|
|
18
|
+
can_drive_ui = excluded.can_drive_ui,
|
|
19
|
+
heartbeat_at = excluded.heartbeat_at,
|
|
20
|
+
registered_at = CASE
|
|
21
|
+
WHEN relay_instances.pid = excluded.pid
|
|
22
|
+
AND relay_instances.process_started_at = excluded.process_started_at
|
|
23
|
+
THEN relay_instances.registered_at ELSE excluded.registered_at END,
|
|
24
|
+
metadata_json = excluded.metadata_json`)
|
|
25
|
+
.run(input.instanceId, input.pid, input.processStartedAt, input.protocolVersion, input.canDriveUi === false ? 0 : 1, at, at, optionalJson(input.metadata));
|
|
26
|
+
return requireRelayInstance(context, input.instanceId);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export function heartbeatRelayInstance(context, identity) {
|
|
30
|
+
return context.immediate(() => {
|
|
31
|
+
const result = context.db
|
|
32
|
+
.prepare(`UPDATE relay_instances SET heartbeat_at = ?
|
|
33
|
+
WHERE instance_id = ? AND pid = ? AND process_started_at = ? AND protocol_version = ?`)
|
|
34
|
+
.run(context.now(), identity.instanceId, identity.pid, identity.processStartedAt, identity.protocolVersion);
|
|
35
|
+
return Number(result.changes) === 1;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function listRelayInstances(context) {
|
|
39
|
+
return context.orm
|
|
40
|
+
.select()
|
|
41
|
+
.from(relayInstances)
|
|
42
|
+
.orderBy(asc(relayInstances.registeredAt), asc(relayInstances.instanceId))
|
|
43
|
+
.all()
|
|
44
|
+
.map(candidate => {
|
|
45
|
+
const row = relayInstanceSelectSchema.parse(candidate);
|
|
46
|
+
return {
|
|
47
|
+
instanceId: row.instanceId,
|
|
48
|
+
pid: row.pid,
|
|
49
|
+
processStartedAt: row.processStartedAt,
|
|
50
|
+
protocolVersion: row.protocolVersion,
|
|
51
|
+
canDriveUi: row.canDriveUi,
|
|
52
|
+
heartbeatAt: row.heartbeatAt,
|
|
53
|
+
registeredAt: row.registeredAt,
|
|
54
|
+
...jsonProperty('metadata', row.metadata)
|
|
55
|
+
};
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function findIncompatibleRelayInstances(context, current, probe = context.processProbe) {
|
|
59
|
+
return listRelayInstances(context).filter(instance => {
|
|
60
|
+
if (!instance.canDriveUi || sameOwner(instance, current) || instance.protocolVersion === current.protocolVersion)
|
|
61
|
+
return false;
|
|
62
|
+
return probeAlive(instance, probe);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
export function requireRelayInstance(context, instanceId) {
|
|
66
|
+
const instance = listRelayInstances(context).find(entry => entry.instanceId === instanceId);
|
|
67
|
+
if (!instance)
|
|
68
|
+
throw new OrchestrationError(`relay instance ${instanceId} does not exist`);
|
|
69
|
+
return instance;
|
|
70
|
+
}
|
|
71
|
+
export function assertRelayRegistered(context, owner) {
|
|
72
|
+
const row = context.db
|
|
73
|
+
.prepare(`SELECT 1 present FROM relay_instances
|
|
74
|
+
WHERE instance_id = ? AND pid = ? AND process_started_at = ? AND protocol_version = ? AND can_drive_ui = 1`)
|
|
75
|
+
.get(owner.instanceId, owner.pid, owner.processStartedAt, owner.protocolVersion);
|
|
76
|
+
if (!row)
|
|
77
|
+
throw new OrchestrationError(`relay ${owner.instanceId} is not registered as this live UI-driving process`);
|
|
78
|
+
}
|