lopata 0.18.4 → 0.19.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/dist/types/api/dispatch.d.ts +2 -2
- package/dist/types/api/handlers/workflows.d.ts +2 -2
- package/dist/types/bindings/container-cleanup.d.ts +43 -0
- package/dist/types/bindings/container-docker.d.ts +24 -0
- package/dist/types/bindings/container.d.ts +4 -2
- package/dist/types/bindings/do-executor-worker.d.ts +73 -89
- package/dist/types/bindings/do-executor.d.ts +22 -1
- package/dist/types/bindings/do-worker-env.d.ts +16 -7
- package/dist/types/bindings/durable-object.d.ts +66 -4
- package/dist/types/bindings/queue.d.ts +4 -1
- package/dist/types/bindings/rpc-stub.d.ts +23 -1
- package/dist/types/bindings/scheduled.d.ts +13 -3
- package/dist/types/bindings/service-binding.d.ts +13 -4
- package/dist/types/bindings/static-assets.d.ts +1 -1
- package/dist/types/bindings/websocket-pair.d.ts +12 -3
- package/dist/types/bindings/workflow.d.ts +29 -0
- package/dist/types/config.d.ts +2 -0
- package/dist/types/env.d.ts +6 -0
- package/dist/types/error-page-render.d.ts +6 -0
- package/dist/types/execution-context.d.ts +8 -0
- package/dist/types/generation-manager.d.ts +21 -2
- package/dist/types/generation.d.ts +16 -21
- package/dist/types/import-graph.d.ts +28 -0
- package/dist/types/lopata-config.d.ts +3 -3
- package/dist/types/plugin.d.ts +4 -1
- package/dist/types/rpc-validate.d.ts +9 -0
- package/dist/types/setup-globals.d.ts +5 -1
- package/dist/types/tracing/context.d.ts +12 -0
- package/dist/types/tracing/span.d.ts +21 -3
- package/dist/types/tracing/store.d.ts +17 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/dist/types/vite-plugin/index.d.ts +8 -1
- package/dist/types/worker-registry.d.ts +11 -4
- package/dist/types/worker-thread/do-protocol.d.ts +256 -0
- package/dist/types/worker-thread/entry.d.ts +2 -0
- package/dist/types/worker-thread/execution-context.d.ts +22 -0
- package/dist/types/worker-thread/executor.d.ts +123 -0
- package/dist/types/worker-thread/protocol.d.ts +553 -0
- package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
- package/dist/types/worker-thread/rpc-client.d.ts +4 -0
- package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
- package/dist/types/worker-thread/serialize.d.ts +12 -0
- package/dist/types/worker-thread/stream-shared.d.ts +147 -0
- package/dist/types/worker-thread/thread-env.d.ts +42 -0
- package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
- package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
- package/package.json +1 -1
- package/src/api/handlers/containers.ts +2 -1
- package/src/api/handlers/workflows.ts +39 -34
- package/src/bindings/container-cleanup.ts +125 -0
- package/src/bindings/container-docker.ts +49 -34
- package/src/bindings/container.ts +24 -9
- package/src/bindings/do-executor-inprocess.ts +9 -5
- package/src/bindings/do-executor-worker.ts +386 -158
- package/src/bindings/do-executor.ts +23 -1
- package/src/bindings/do-worker-entry.ts +242 -60
- package/src/bindings/do-worker-env.ts +296 -11
- package/src/bindings/durable-object.ts +231 -27
- package/src/bindings/email.ts +6 -0
- package/src/bindings/queue.ts +11 -1
- package/src/bindings/rpc-stub.ts +79 -10
- package/src/bindings/scheduled.ts +37 -30
- package/src/bindings/service-binding.ts +87 -33
- package/src/bindings/static-assets.ts +3 -2
- package/src/bindings/websocket-pair.ts +19 -3
- package/src/bindings/workflow.ts +91 -0
- package/src/cli/dev.ts +106 -41
- package/src/config.ts +6 -3
- package/src/db.ts +1 -0
- package/src/env.ts +40 -21
- package/src/error-page-render.ts +21 -0
- package/src/execution-context.ts +13 -3
- package/src/generation-manager.ts +144 -143
- package/src/generation.ts +150 -306
- package/src/import-graph.ts +140 -0
- package/src/lopata-config.ts +3 -3
- package/src/plugin.ts +7 -0
- package/src/rpc-validate.ts +29 -0
- package/src/setup-globals.ts +6 -17
- package/src/testing/durable-object.ts +5 -3
- package/src/testing/index.ts +8 -4
- package/src/tracing/context.ts +18 -0
- package/src/tracing/span.ts +83 -57
- package/src/tracing/store.ts +41 -3
- package/src/virtual-modules.ts +2 -0
- package/src/vite-plugin/dev-server-plugin.ts +4 -0
- package/src/vite-plugin/index.ts +8 -1
- package/src/worker-registry.ts +15 -2
- package/src/worker-thread/do-protocol.ts +237 -0
- package/src/worker-thread/entry.ts +453 -0
- package/src/worker-thread/execution-context.ts +53 -0
- package/src/worker-thread/executor.ts +595 -0
- package/src/worker-thread/protocol.ts +552 -0
- package/src/worker-thread/remote-trace-store.ts +90 -0
- package/src/worker-thread/rpc-client.ts +5 -0
- package/src/worker-thread/rpc-shared.ts +503 -0
- package/src/worker-thread/serialize.ts +37 -0
- package/src/worker-thread/stream-shared.ts +414 -0
- package/src/worker-thread/thread-env.ts +350 -0
- package/src/worker-thread/wire-handlers.ts +80 -0
- package/src/worker-thread/ws-bridge-shared.ts +482 -0
- package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
- package/dist/types/module-cache.d.ts +0 -23
- package/src/bindings/do-websocket-bridge.ts +0 -79
- package/src/module-cache.ts +0 -58
- package/src/tracing/global.d.ts +0 -50
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { SQLQueryBindings } from 'bun:sqlite'
|
|
2
2
|
import type { SqliteWorkflowBinding } from '../../bindings/workflow'
|
|
3
|
-
import { getWaitingEventTypes, isInstanceSleeping } from '../../bindings/workflow'
|
|
4
3
|
import { getDatabase } from '../../db'
|
|
5
4
|
import type { HandlerContext, OkResponse, WorkflowDetail, WorkflowInstance, WorkflowSummary } from '../types'
|
|
6
5
|
import { getAllConfigs } from '../types'
|
|
@@ -66,7 +65,7 @@ export const handlers = {
|
|
|
66
65
|
return db.prepare(query).all(...params) as WorkflowInstance[]
|
|
67
66
|
},
|
|
68
67
|
|
|
69
|
-
'workflows.getInstance'({ id }: { name: string; id: string }): WorkflowDetail {
|
|
68
|
+
async 'workflows.getInstance'({ name, id }: { name: string; id: string }, ctx: HandlerContext): Promise<WorkflowDetail> {
|
|
70
69
|
const db = getDatabase()
|
|
71
70
|
const instance = db.query<Record<string, unknown>, [string]>(
|
|
72
71
|
'SELECT * FROM workflow_instances WHERE id = ?',
|
|
@@ -95,10 +94,28 @@ export const handlers = {
|
|
|
95
94
|
'SELECT id, event_type, payload, created_at FROM workflow_events WHERE instance_id = ? ORDER BY created_at',
|
|
96
95
|
).all(id)
|
|
97
96
|
|
|
98
|
-
//
|
|
97
|
+
// The "sleeping" / "waiting for events" introspection reads live-process
|
|
98
|
+
// in-memory registries. In thread mode those registries are populated in
|
|
99
|
+
// the worker, so route the reads through the binding (the thread router
|
|
100
|
+
// forwards them); in-process the binding runs them locally. Resolve the
|
|
101
|
+
// binding best-effort — a stopped/unknown worker just yields the defaults.
|
|
102
|
+
let sleeping = false
|
|
103
|
+
let waitingForEvents: string[] = []
|
|
104
|
+
try {
|
|
105
|
+
const binding = getWorkflowBinding(ctx, name)
|
|
106
|
+
if (instance.status === 'running') {
|
|
107
|
+
const r = await binding.executeControl({ kind: 'isSleeping', instanceId: id })
|
|
108
|
+
if (r.kind === 'isSleeping') sleeping = r.value
|
|
109
|
+
}
|
|
110
|
+
if (instance.status === 'waiting') {
|
|
111
|
+
const r = await binding.executeControl({ kind: 'waitingEventTypes', instanceId: id })
|
|
112
|
+
if (r.kind === 'waitingEventTypes') waitingForEvents = r.value
|
|
113
|
+
}
|
|
114
|
+
} catch {}
|
|
115
|
+
|
|
116
|
+
// Compute active sleep: find the latest sleep/sleepUntil step "until" time.
|
|
99
117
|
let activeSleep: WorkflowDetail['activeSleep'] = null
|
|
100
|
-
if (
|
|
101
|
-
// Find the latest sleep/sleepUntil step to get the "until" time
|
|
118
|
+
if (sleeping) {
|
|
102
119
|
for (let i = steps.length - 1; i >= 0; i--) {
|
|
103
120
|
const s = steps[i]!
|
|
104
121
|
if ((s.step_name.startsWith('sleep:') || s.step_name.startsWith('sleepUntil:')) && s.output) {
|
|
@@ -114,51 +131,37 @@ export const handlers = {
|
|
|
114
131
|
}
|
|
115
132
|
}
|
|
116
133
|
|
|
117
|
-
// Compute waiting event types from in-memory registry
|
|
118
|
-
const waitingForEvents = instance.status === 'waiting' ? getWaitingEventTypes(id) : []
|
|
119
|
-
|
|
120
134
|
return { ...instance, steps, stepAttempts, events, activeSleep, waitingForEvents } as WorkflowDetail
|
|
121
135
|
},
|
|
122
136
|
|
|
123
137
|
async 'workflows.terminate'({ name, id }: { name: string; id: string }, ctx: HandlerContext): Promise<OkResponse> {
|
|
124
|
-
|
|
125
|
-
const instance = await binding.get(id)
|
|
126
|
-
await instance.terminate()
|
|
138
|
+
await getWorkflowBinding(ctx, name).executeControl({ kind: 'terminate', instanceId: id })
|
|
127
139
|
return { ok: true }
|
|
128
140
|
},
|
|
129
141
|
|
|
130
142
|
async 'workflows.create'({ name, params }: { name: string; params: string }, ctx: HandlerContext): Promise<{ ok: true; id: string }> {
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
return { ok: true, id: instance.id }
|
|
143
|
+
const result = await getWorkflowBinding(ctx, name).executeControl({ kind: 'create', params: JSON.parse(params) })
|
|
144
|
+
if (result.kind !== 'create') throw new Error('Unexpected workflow control result for create')
|
|
145
|
+
return { ok: true, id: result.id }
|
|
135
146
|
},
|
|
136
147
|
|
|
137
148
|
async 'workflows.pause'({ name, id }: { name: string; id: string }, ctx: HandlerContext): Promise<OkResponse> {
|
|
138
|
-
|
|
139
|
-
const instance = await binding.get(id)
|
|
140
|
-
await instance.pause()
|
|
149
|
+
await getWorkflowBinding(ctx, name).executeControl({ kind: 'pause', instanceId: id })
|
|
141
150
|
return { ok: true }
|
|
142
151
|
},
|
|
143
152
|
|
|
144
153
|
async 'workflows.resume'({ name, id }: { name: string; id: string }, ctx: HandlerContext): Promise<OkResponse> {
|
|
145
|
-
|
|
146
|
-
const instance = await binding.get(id)
|
|
147
|
-
await instance.resume()
|
|
154
|
+
await getWorkflowBinding(ctx, name).executeControl({ kind: 'resume', instanceId: id })
|
|
148
155
|
return { ok: true }
|
|
149
156
|
},
|
|
150
157
|
|
|
151
158
|
async 'workflows.restart'({ name, id, fromStep }: { name: string; id: string; fromStep?: string }, ctx: HandlerContext): Promise<OkResponse> {
|
|
152
|
-
|
|
153
|
-
const instance = await binding.get(id)
|
|
154
|
-
await instance.restart(fromStep ? { fromStep } : undefined)
|
|
159
|
+
await getWorkflowBinding(ctx, name).executeControl({ kind: 'restart', instanceId: id, fromStep })
|
|
155
160
|
return { ok: true }
|
|
156
161
|
},
|
|
157
162
|
|
|
158
163
|
async 'workflows.skipSleep'({ name, id }: { name: string; id: string }, ctx: HandlerContext): Promise<OkResponse> {
|
|
159
|
-
|
|
160
|
-
const instance = await binding.get(id)
|
|
161
|
-
await instance.skipSleep()
|
|
164
|
+
await getWorkflowBinding(ctx, name).executeControl({ kind: 'skipSleep', instanceId: id })
|
|
162
165
|
return { ok: true }
|
|
163
166
|
},
|
|
164
167
|
|
|
@@ -166,22 +169,24 @@ export const handlers = {
|
|
|
166
169
|
{ name, id, type, payload }: { name: string; id: string; type: string; payload?: string },
|
|
167
170
|
ctx: HandlerContext,
|
|
168
171
|
): Promise<OkResponse> {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
await getWorkflowBinding(ctx, name).executeControl({
|
|
173
|
+
kind: 'sendEvent',
|
|
174
|
+
instanceId: id,
|
|
175
|
+
eventType: type,
|
|
176
|
+
payload: payload ? JSON.parse(payload) : undefined,
|
|
177
|
+
})
|
|
173
178
|
return { ok: true }
|
|
174
179
|
},
|
|
175
180
|
|
|
176
181
|
async 'workflows.duplicate'({ name, id }: { name: string; id: string }, ctx: HandlerContext): Promise<{ ok: true; id: string }> {
|
|
177
|
-
const binding = getWorkflowBinding(ctx, name)
|
|
178
182
|
const db = getDatabase()
|
|
179
183
|
const row = db.query<{ params: string | null }, [string]>(
|
|
180
184
|
'SELECT params FROM workflow_instances WHERE id = ?',
|
|
181
185
|
).get(id)
|
|
182
186
|
if (!row) throw new Error('Workflow instance not found')
|
|
183
187
|
const params = row.params !== null ? JSON.parse(row.params) : {}
|
|
184
|
-
const
|
|
185
|
-
|
|
188
|
+
const result = await getWorkflowBinding(ctx, name).executeControl({ kind: 'create', params })
|
|
189
|
+
if (result.kind !== 'create') throw new Error('Unexpected workflow control result for duplicate')
|
|
190
|
+
return { ok: true, id: result.id }
|
|
186
191
|
},
|
|
187
192
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main-side registry of active Docker containers spawned by lopata.
|
|
3
|
+
*
|
|
4
|
+
* Containers come from two places: the in-process path (dashboard API
|
|
5
|
+
* handlers + the legacy/vite-plugin executor in `env.ts`) and the DO worker
|
|
6
|
+
* threads. DO workers call `register` / `unregister` via postMessage
|
|
7
|
+
* (`container-registered` / `container-removed` in `do-executor-worker.ts`);
|
|
8
|
+
* the in-process callers invoke the helpers directly through the
|
|
9
|
+
* `DockerManager` constructor callbacks.
|
|
10
|
+
*
|
|
11
|
+
* Two safety nets:
|
|
12
|
+
*
|
|
13
|
+
* 1. An `exit` listener `docker rm -f`s anything still tracked at process
|
|
14
|
+
* shutdown. Synchronous (`Bun.spawnSync`) since `exit` doesn't await.
|
|
15
|
+
*
|
|
16
|
+
* 2. `reapOrphanContainers()` scans for containers labeled `lopata=1`
|
|
17
|
+
* whose `lopata.pid` is no longer alive — cleans up leftovers from
|
|
18
|
+
* crashes / SIGKILL where (1) never ran. Invoked once at lopata
|
|
19
|
+
* startup from `cli/dev.ts`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { $ } from 'bun'
|
|
23
|
+
|
|
24
|
+
/** Marker label on every lopata-managed container. */
|
|
25
|
+
export const LOPATA_LABEL_KEY = 'lopata'
|
|
26
|
+
export const LOPATA_LABEL_VALUE = '1'
|
|
27
|
+
/** Pid sub-label so the reaper can detect orphans of dead processes. */
|
|
28
|
+
export const LOPATA_PID_LABEL_KEY = 'lopata.pid'
|
|
29
|
+
|
|
30
|
+
/** Labels passed to `DockerManager` (constructor) for every `docker run`. */
|
|
31
|
+
export function containerLabels(): Record<string, string> {
|
|
32
|
+
return {
|
|
33
|
+
[LOPATA_LABEL_KEY]: LOPATA_LABEL_VALUE,
|
|
34
|
+
[LOPATA_PID_LABEL_KEY]: String(process.pid),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const activeContainers = new Set<string>()
|
|
39
|
+
let exitHandlerRegistered = false
|
|
40
|
+
|
|
41
|
+
function ensureExitHandlerRegistered(): void {
|
|
42
|
+
if (exitHandlerRegistered) return
|
|
43
|
+
exitHandlerRegistered = true
|
|
44
|
+
process.on('exit', () => {
|
|
45
|
+
if (activeContainers.size === 0) return
|
|
46
|
+
// `exit` is synchronous-only; a single batched call keeps shutdown
|
|
47
|
+
// snappy even with many containers. `docker rm -f` accepts a list.
|
|
48
|
+
try {
|
|
49
|
+
Bun.spawnSync(['docker', 'rm', '-f', ...activeContainers])
|
|
50
|
+
} catch {
|
|
51
|
+
// best-effort cleanup
|
|
52
|
+
}
|
|
53
|
+
activeContainers.clear()
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function registerContainer(name: string): void {
|
|
58
|
+
ensureExitHandlerRegistered()
|
|
59
|
+
activeContainers.add(name)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function unregisterContainer(name: string): void {
|
|
63
|
+
activeContainers.delete(name)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** @internal Test hook — current tracked container set. */
|
|
67
|
+
export function _activeContainersSnapshot(): string[] {
|
|
68
|
+
return [...activeContainers]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Reap containers labeled `lopata=1` whose `lopata.pid` is no longer alive.
|
|
73
|
+
* Returns the number of orphans removed. Silently returns 0 if docker isn't
|
|
74
|
+
* available so non-container users aren't penalised.
|
|
75
|
+
*/
|
|
76
|
+
export async function reapOrphanContainers(): Promise<number> {
|
|
77
|
+
let listOut: string
|
|
78
|
+
try {
|
|
79
|
+
const format = `{{.Names}}|{{.Label "${LOPATA_PID_LABEL_KEY}"}}`
|
|
80
|
+
const result = await $`docker ps -a --filter label=${LOPATA_LABEL_KEY}=${LOPATA_LABEL_VALUE} --format ${format}`.quiet().nothrow()
|
|
81
|
+
if (result.exitCode !== 0) return 0
|
|
82
|
+
listOut = result.stdout.toString()
|
|
83
|
+
} catch {
|
|
84
|
+
return 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const orphans = _selectOrphanNames(listOut, isProcessAlive)
|
|
88
|
+
if (orphans.length === 0) return 0
|
|
89
|
+
await $`docker rm -f ${orphans}`.quiet().nothrow()
|
|
90
|
+
return orphans.length
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @internal Pure-function core of {@link reapOrphanContainers}, exported for
|
|
95
|
+
* tests. Parses the `docker ps` `{{.Names}}|{{.Label ...}}` table and returns
|
|
96
|
+
* the container names whose owner pid is unknown, unparseable, or dead.
|
|
97
|
+
*/
|
|
98
|
+
export function _selectOrphanNames(listOut: string, alive: (pid: number) => boolean): string[] {
|
|
99
|
+
const orphans: string[] = []
|
|
100
|
+
for (const line of listOut.split('\n')) {
|
|
101
|
+
if (!line) continue
|
|
102
|
+
const sep = line.indexOf('|')
|
|
103
|
+
const name = sep === -1 ? line : line.slice(0, sep)
|
|
104
|
+
const pidStr = sep === -1 ? '' : line.slice(sep + 1)
|
|
105
|
+
if (!name) continue
|
|
106
|
+
const pid = pidStr ? Number.parseInt(pidStr, 10) : Number.NaN
|
|
107
|
+
// Live pid → another lopata owns it. Missing/unparseable/dead/≤0 → orphan.
|
|
108
|
+
if (Number.isFinite(pid) && pid > 0 && alive(pid)) continue
|
|
109
|
+
orphans.push(name)
|
|
110
|
+
}
|
|
111
|
+
return orphans
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isProcessAlive(pid: number): boolean {
|
|
115
|
+
// `process.kill(0, 0)` on POSIX signals the whole process group, which
|
|
116
|
+
// succeeds whenever the caller has a group — so pid 0 must NOT be treated
|
|
117
|
+
// as "alive". Negative pids are invalid; treat both as dead.
|
|
118
|
+
if (pid <= 0) return false
|
|
119
|
+
try {
|
|
120
|
+
process.kill(pid, 0)
|
|
121
|
+
return true
|
|
122
|
+
} catch {
|
|
123
|
+
return false
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -15,36 +15,9 @@ export interface DockerContainerInfo {
|
|
|
15
15
|
state: string // "running", "exited", "created", etc.
|
|
16
16
|
exitCode: number | null
|
|
17
17
|
ports: Record<string, string>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const activeContainers = new Set<string>()
|
|
22
|
-
let cleanupRegistered = false
|
|
23
|
-
|
|
24
|
-
function registerCleanup() {
|
|
25
|
-
if (cleanupRegistered) return
|
|
26
|
-
cleanupRegistered = true
|
|
27
|
-
|
|
28
|
-
const cleanup = () => {
|
|
29
|
-
for (const name of activeContainers) {
|
|
30
|
-
try {
|
|
31
|
-
Bun.spawnSync(['docker', 'rm', '-f', name])
|
|
32
|
-
} catch {
|
|
33
|
-
// best-effort cleanup
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
activeContainers.clear()
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
process.on('SIGINT', () => {
|
|
40
|
-
cleanup()
|
|
41
|
-
process.exit(130)
|
|
42
|
-
})
|
|
43
|
-
process.on('SIGTERM', () => {
|
|
44
|
-
cleanup()
|
|
45
|
-
process.exit(143)
|
|
46
|
-
})
|
|
47
|
-
process.on('exit', cleanup)
|
|
18
|
+
/** Container labels (e.g. `lopata.pid`) — used to detect a container left by a
|
|
19
|
+
* crashed/foreign lopata process before adopting it by name. */
|
|
20
|
+
labels: Record<string, string>
|
|
48
21
|
}
|
|
49
22
|
|
|
50
23
|
// Image build cache: tag -> { mtime }
|
|
@@ -52,7 +25,29 @@ const imageCache = new Map<string, { mtime: number }>()
|
|
|
52
25
|
|
|
53
26
|
const DOCKER_JSON_FORMAT = '{{json .}}'
|
|
54
27
|
|
|
28
|
+
export interface DockerManagerOptions {
|
|
29
|
+
/** Called after a container is successfully created via `run()`. */
|
|
30
|
+
onRegister?: (name: string) => void
|
|
31
|
+
/** Called after a container is removed via `remove()`. */
|
|
32
|
+
onRemove?: (name: string) => void
|
|
33
|
+
/**
|
|
34
|
+
* Labels passed to every `docker run` (`--label key=value`).
|
|
35
|
+
* Cleanup uses these to detect orphans from crashed processes.
|
|
36
|
+
*/
|
|
37
|
+
labels?: Record<string, string>
|
|
38
|
+
}
|
|
39
|
+
|
|
55
40
|
export class DockerManager {
|
|
41
|
+
private _onRegister?: (name: string) => void
|
|
42
|
+
private _onRemove?: (name: string) => void
|
|
43
|
+
private _labels?: Record<string, string>
|
|
44
|
+
|
|
45
|
+
constructor(options?: DockerManagerOptions) {
|
|
46
|
+
this._onRegister = options?.onRegister
|
|
47
|
+
this._onRemove = options?.onRemove
|
|
48
|
+
this._labels = options?.labels
|
|
49
|
+
}
|
|
50
|
+
|
|
56
51
|
/**
|
|
57
52
|
* Build an image from a Dockerfile, with lazy mtime-based caching.
|
|
58
53
|
* Skips rebuild if the Dockerfile hasn't changed since last build.
|
|
@@ -80,10 +75,15 @@ export class DockerManager {
|
|
|
80
75
|
* Run a container and return its container ID.
|
|
81
76
|
*/
|
|
82
77
|
async run(options: DockerRunOptions): Promise<string> {
|
|
83
|
-
registerCleanup()
|
|
84
|
-
|
|
85
78
|
const args: string[] = ['docker', 'run', '-d', '--name', options.name]
|
|
86
79
|
|
|
80
|
+
// Labels (orphan reaping etc.)
|
|
81
|
+
if (this._labels) {
|
|
82
|
+
for (const [key, value] of Object.entries(this._labels)) {
|
|
83
|
+
args.push('--label', `${key}=${value}`)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
87
|
// Port mappings
|
|
88
88
|
for (const [containerPort, hostPort] of options.ports) {
|
|
89
89
|
args.push('-p', `${hostPort}:${containerPort}`)
|
|
@@ -120,10 +120,19 @@ export class DockerManager {
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
const containerId = result.stdout.toString().trim()
|
|
123
|
-
|
|
123
|
+
this._onRegister?.(options.name)
|
|
124
124
|
return containerId
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Fire the `onRegister` callback for a container we didn't create via
|
|
129
|
+
* `run()` — e.g. one adopted at startup because it was already running.
|
|
130
|
+
* Lets cleanup hooks (process-exit handler) track it the same way.
|
|
131
|
+
*/
|
|
132
|
+
registerExisting(name: string): void {
|
|
133
|
+
this._onRegister?.(name)
|
|
134
|
+
}
|
|
135
|
+
|
|
127
136
|
/**
|
|
128
137
|
* Stop a running container gracefully.
|
|
129
138
|
*/
|
|
@@ -172,12 +181,18 @@ export class DockerManager {
|
|
|
172
181
|
}
|
|
173
182
|
|
|
174
183
|
const state = data.State?.Status ?? 'unknown'
|
|
184
|
+
const rawLabels = data.Config?.Labels ?? {}
|
|
185
|
+
const labels: Record<string, string> = {}
|
|
186
|
+
for (const [k, v] of Object.entries(rawLabels)) {
|
|
187
|
+
if (typeof v === 'string') labels[k] = v
|
|
188
|
+
}
|
|
175
189
|
return {
|
|
176
190
|
id: data.Id ?? '',
|
|
177
191
|
name: (data.Name ?? '').replace(/^\//, ''),
|
|
178
192
|
state,
|
|
179
193
|
exitCode: state === 'running' ? null : (data.State?.ExitCode ?? null),
|
|
180
194
|
ports,
|
|
195
|
+
labels,
|
|
181
196
|
}
|
|
182
197
|
} catch {
|
|
183
198
|
return null
|
|
@@ -202,7 +217,7 @@ export class DockerManager {
|
|
|
202
217
|
*/
|
|
203
218
|
async remove(name: string): Promise<void> {
|
|
204
219
|
await $`docker rm -f ${name}`.quiet().nothrow()
|
|
205
|
-
|
|
220
|
+
this._onRemove?.(name)
|
|
206
221
|
}
|
|
207
222
|
|
|
208
223
|
/**
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { LOPATA_PID_LABEL_KEY } from './container-cleanup'
|
|
1
2
|
import { DockerManager, type DockerRunOptions } from './container-docker'
|
|
2
3
|
import { DurableObjectBase, type DurableObjectStateImpl } from './durable-object'
|
|
3
4
|
|
|
@@ -97,20 +98,32 @@ export class ContainerRuntime {
|
|
|
97
98
|
|
|
98
99
|
this._transition('running')
|
|
99
100
|
|
|
100
|
-
// Check if a container with this name already exists (e.g.
|
|
101
|
+
// Check if a container with this name already exists (e.g. survived a reload
|
|
102
|
+
// in this same process, or was left behind by a crashed/foreign lopata run).
|
|
101
103
|
const existing = await this._docker.inspect(this._containerName)
|
|
102
104
|
if (existing) {
|
|
103
|
-
|
|
105
|
+
// Only adopt a running container THIS process created (matching pid
|
|
106
|
+
// label). A stale label means the creator crashed/exited — its config,
|
|
107
|
+
// env and port mappings may not match what we'd build now, so removing
|
|
108
|
+
// and recreating is safer than silently adopting foreign state. (Labels
|
|
109
|
+
// are immutable, so an adopted container keeps the dead creator's pid.)
|
|
110
|
+
const ownerPid = existing.labels[LOPATA_PID_LABEL_KEY]
|
|
111
|
+
const ownedByThisProcess = ownerPid === String(process.pid)
|
|
112
|
+
if (existing.state === 'running' && ownedByThisProcess) {
|
|
104
113
|
// Recover port mappings from the running container
|
|
105
114
|
this._hostPorts.clear()
|
|
106
115
|
this._recoverPortMappings(existing.ports)
|
|
116
|
+
// `run()` would have fired onRegister; do the same for adoption so
|
|
117
|
+
// the process-exit cleanup tracks recovered containers too.
|
|
118
|
+
this._docker.registerExisting(this._containerName)
|
|
107
119
|
await this.onStart?.()
|
|
108
120
|
this._startHealthCheck()
|
|
109
121
|
this._startMonitor()
|
|
110
122
|
this.renewActivityTimeout()
|
|
111
123
|
return
|
|
112
124
|
}
|
|
113
|
-
//
|
|
125
|
+
// Not running, or owned by a different/dead lopata process — remove it
|
|
126
|
+
// before creating a fresh one.
|
|
114
127
|
await this._docker.remove(this._containerName)
|
|
115
128
|
}
|
|
116
129
|
|
|
@@ -292,15 +305,17 @@ export class ContainerRuntime {
|
|
|
292
305
|
// ─── Private ────────────────────────────────────────────────────────────
|
|
293
306
|
|
|
294
307
|
/**
|
|
295
|
-
* Recover host port mappings from
|
|
296
|
-
*
|
|
308
|
+
* Recover host port mappings from the flattened ports map returned by
|
|
309
|
+
* `DockerManager.inspect()`: { "8080/tcp": "0.0.0.0:32768", ... }.
|
|
310
|
+
* (inspect() already flattens docker's raw `[{HostIp,HostPort}]` shape, so
|
|
311
|
+
* the value here is a "host:port" string, not an array.)
|
|
297
312
|
*/
|
|
298
|
-
private _recoverPortMappings(ports: Record<string,
|
|
299
|
-
for (const [key,
|
|
300
|
-
if (
|
|
313
|
+
private _recoverPortMappings(ports: Record<string, string>) {
|
|
314
|
+
for (const [key, value] of Object.entries(ports)) {
|
|
315
|
+
if (typeof value !== 'string') continue
|
|
301
316
|
const containerPort = parseInt(key, 10)
|
|
302
317
|
if (Number.isNaN(containerPort)) continue
|
|
303
|
-
const hostPort = parseInt(
|
|
318
|
+
const hostPort = parseInt(value.split(':').pop() ?? '', 10)
|
|
304
319
|
if (Number.isNaN(hostPort)) continue
|
|
305
320
|
this._hostPorts.set(containerPort, hostPort)
|
|
306
321
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { warnInvalidRpcArgs } from '../rpc-validate'
|
|
2
2
|
import type { DOExecutor, DOExecutorFactory, ExecutorConfig } from './do-executor'
|
|
3
3
|
import { type DurableObjectBase, DurableObjectStateImpl } from './durable-object'
|
|
4
|
-
import { createRpcFunctionStub, wrapRpcReturnValue } from './rpc-stub'
|
|
5
4
|
|
|
6
5
|
export class InProcessExecutor implements DOExecutor {
|
|
7
6
|
private _state: DurableObjectStateImpl
|
|
@@ -63,8 +62,11 @@ export class InProcessExecutor implements DOExecutor {
|
|
|
63
62
|
try {
|
|
64
63
|
const val = (this._instance as unknown as Record<string, unknown>)[method]
|
|
65
64
|
if (typeof val === 'function') {
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// Return the raw result — the namespace `get()` stub wraps it once via
|
|
66
|
+
// wrapRpcReturnValue, matching the worker-thread executor (whose
|
|
67
|
+
// executeRpc also returns a raw value). Wrapping here too would
|
|
68
|
+
// double-wrap and emit warnInvalidRpcReturn twice.
|
|
69
|
+
return await (val as (...a: unknown[]) => unknown).call(this._instance, ...args)
|
|
68
70
|
}
|
|
69
71
|
throw new Error(`"${method}" is not a method on the Durable Object`)
|
|
70
72
|
} finally {
|
|
@@ -77,9 +79,11 @@ export class InProcessExecutor implements DOExecutor {
|
|
|
77
79
|
try {
|
|
78
80
|
const val = (this._instance as unknown as Record<string, unknown>)[prop]
|
|
79
81
|
if (typeof val === 'function') {
|
|
80
|
-
|
|
82
|
+
// Re-dispatching callable (mirrors WorkerExecutor.executeRpcGet); the
|
|
83
|
+
// stub wraps it once. Avoids the double function-stub wrap.
|
|
84
|
+
return (...args: unknown[]) => this.executeRpc(prop, args)
|
|
81
85
|
}
|
|
82
|
-
return
|
|
86
|
+
return val
|
|
83
87
|
} finally {
|
|
84
88
|
this._state._exit()
|
|
85
89
|
}
|