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.
Files changed (106) hide show
  1. package/dist/types/api/dispatch.d.ts +2 -2
  2. package/dist/types/api/handlers/workflows.d.ts +2 -2
  3. package/dist/types/bindings/container-cleanup.d.ts +43 -0
  4. package/dist/types/bindings/container-docker.d.ts +24 -0
  5. package/dist/types/bindings/container.d.ts +4 -2
  6. package/dist/types/bindings/do-executor-worker.d.ts +73 -89
  7. package/dist/types/bindings/do-executor.d.ts +22 -1
  8. package/dist/types/bindings/do-worker-env.d.ts +16 -7
  9. package/dist/types/bindings/durable-object.d.ts +66 -4
  10. package/dist/types/bindings/queue.d.ts +4 -1
  11. package/dist/types/bindings/rpc-stub.d.ts +23 -1
  12. package/dist/types/bindings/scheduled.d.ts +13 -3
  13. package/dist/types/bindings/service-binding.d.ts +13 -4
  14. package/dist/types/bindings/static-assets.d.ts +1 -1
  15. package/dist/types/bindings/websocket-pair.d.ts +12 -3
  16. package/dist/types/bindings/workflow.d.ts +29 -0
  17. package/dist/types/config.d.ts +2 -0
  18. package/dist/types/env.d.ts +6 -0
  19. package/dist/types/error-page-render.d.ts +6 -0
  20. package/dist/types/execution-context.d.ts +8 -0
  21. package/dist/types/generation-manager.d.ts +21 -2
  22. package/dist/types/generation.d.ts +16 -21
  23. package/dist/types/import-graph.d.ts +28 -0
  24. package/dist/types/lopata-config.d.ts +3 -3
  25. package/dist/types/plugin.d.ts +4 -1
  26. package/dist/types/rpc-validate.d.ts +9 -0
  27. package/dist/types/setup-globals.d.ts +5 -1
  28. package/dist/types/tracing/context.d.ts +12 -0
  29. package/dist/types/tracing/span.d.ts +21 -3
  30. package/dist/types/tracing/store.d.ts +17 -0
  31. package/dist/types/tsconfig.tsbuildinfo +1 -1
  32. package/dist/types/vite-plugin/index.d.ts +8 -1
  33. package/dist/types/worker-registry.d.ts +11 -4
  34. package/dist/types/worker-thread/do-protocol.d.ts +256 -0
  35. package/dist/types/worker-thread/entry.d.ts +2 -0
  36. package/dist/types/worker-thread/execution-context.d.ts +22 -0
  37. package/dist/types/worker-thread/executor.d.ts +123 -0
  38. package/dist/types/worker-thread/protocol.d.ts +553 -0
  39. package/dist/types/worker-thread/remote-trace-store.d.ts +27 -0
  40. package/dist/types/worker-thread/rpc-client.d.ts +4 -0
  41. package/dist/types/worker-thread/rpc-shared.d.ts +138 -0
  42. package/dist/types/worker-thread/serialize.d.ts +12 -0
  43. package/dist/types/worker-thread/stream-shared.d.ts +147 -0
  44. package/dist/types/worker-thread/thread-env.d.ts +42 -0
  45. package/dist/types/worker-thread/wire-handlers.d.ts +16 -0
  46. package/dist/types/worker-thread/ws-bridge-shared.d.ts +163 -0
  47. package/package.json +1 -1
  48. package/src/api/handlers/containers.ts +2 -1
  49. package/src/api/handlers/workflows.ts +39 -34
  50. package/src/bindings/container-cleanup.ts +125 -0
  51. package/src/bindings/container-docker.ts +49 -34
  52. package/src/bindings/container.ts +24 -9
  53. package/src/bindings/do-executor-inprocess.ts +9 -5
  54. package/src/bindings/do-executor-worker.ts +386 -158
  55. package/src/bindings/do-executor.ts +23 -1
  56. package/src/bindings/do-worker-entry.ts +242 -60
  57. package/src/bindings/do-worker-env.ts +296 -11
  58. package/src/bindings/durable-object.ts +231 -27
  59. package/src/bindings/email.ts +6 -0
  60. package/src/bindings/queue.ts +11 -1
  61. package/src/bindings/rpc-stub.ts +79 -10
  62. package/src/bindings/scheduled.ts +37 -30
  63. package/src/bindings/service-binding.ts +87 -33
  64. package/src/bindings/static-assets.ts +3 -2
  65. package/src/bindings/websocket-pair.ts +19 -3
  66. package/src/bindings/workflow.ts +91 -0
  67. package/src/cli/dev.ts +106 -41
  68. package/src/config.ts +6 -3
  69. package/src/db.ts +1 -0
  70. package/src/env.ts +40 -21
  71. package/src/error-page-render.ts +21 -0
  72. package/src/execution-context.ts +13 -3
  73. package/src/generation-manager.ts +144 -143
  74. package/src/generation.ts +150 -306
  75. package/src/import-graph.ts +140 -0
  76. package/src/lopata-config.ts +3 -3
  77. package/src/plugin.ts +7 -0
  78. package/src/rpc-validate.ts +29 -0
  79. package/src/setup-globals.ts +6 -17
  80. package/src/testing/durable-object.ts +5 -3
  81. package/src/testing/index.ts +8 -4
  82. package/src/tracing/context.ts +18 -0
  83. package/src/tracing/span.ts +83 -57
  84. package/src/tracing/store.ts +41 -3
  85. package/src/virtual-modules.ts +2 -0
  86. package/src/vite-plugin/dev-server-plugin.ts +4 -0
  87. package/src/vite-plugin/index.ts +8 -1
  88. package/src/worker-registry.ts +15 -2
  89. package/src/worker-thread/do-protocol.ts +237 -0
  90. package/src/worker-thread/entry.ts +453 -0
  91. package/src/worker-thread/execution-context.ts +53 -0
  92. package/src/worker-thread/executor.ts +595 -0
  93. package/src/worker-thread/protocol.ts +552 -0
  94. package/src/worker-thread/remote-trace-store.ts +90 -0
  95. package/src/worker-thread/rpc-client.ts +5 -0
  96. package/src/worker-thread/rpc-shared.ts +503 -0
  97. package/src/worker-thread/serialize.ts +37 -0
  98. package/src/worker-thread/stream-shared.ts +414 -0
  99. package/src/worker-thread/thread-env.ts +350 -0
  100. package/src/worker-thread/wire-handlers.ts +80 -0
  101. package/src/worker-thread/ws-bridge-shared.ts +482 -0
  102. package/dist/types/bindings/do-websocket-bridge.d.ts +0 -60
  103. package/dist/types/module-cache.d.ts +0 -23
  104. package/src/bindings/do-websocket-bridge.ts +0 -79
  105. package/src/module-cache.ts +0 -58
  106. 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
- // Compute active sleep: check if the instance is currently sleeping (in-memory)
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 (instance.status === 'running' && isInstanceSleeping(id)) {
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
- const binding = getWorkflowBinding(ctx, name)
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 binding = getWorkflowBinding(ctx, name)
132
- const parsed = JSON.parse(params)
133
- const instance = await binding.create({ params: parsed })
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
- const binding = getWorkflowBinding(ctx, name)
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
- const binding = getWorkflowBinding(ctx, name)
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
- const binding = getWorkflowBinding(ctx, name)
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
- const binding = getWorkflowBinding(ctx, name)
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
- const binding = getWorkflowBinding(ctx, name)
170
- const instance = await binding.get(id)
171
- const parsed = payload ? JSON.parse(payload) : undefined
172
- await instance.sendEvent({ type, payload: parsed })
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 newInstance = await binding.create({ params })
185
- return { ok: true, id: newInstance.id }
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
- // Track active containers for cleanup on process exit
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
- activeContainers.add(options.name)
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
- activeContainers.delete(name)
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. after process crash)
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
- if (existing.state === 'running') {
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
- // Container exists but isn't running remove it before creating a new one
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 docker inspect Ports object.
296
- * Format: { "8080/tcp": [{ "HostIp": "...", "HostPort": "49152" }], ... }
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, unknown>) {
299
- for (const [key, bindings] of Object.entries(ports)) {
300
- if (!Array.isArray(bindings) || bindings.length === 0) continue
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(bindings[0].HostPort, 10)
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
- const result = await (val as (...a: unknown[]) => unknown).call(this._instance, ...args)
67
- return wrapRpcReturnValue(result, method)
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
- return createRpcFunctionStub(val as Function, this._instance)
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 wrapRpcReturnValue(val, prop)
86
+ return val
83
87
  } finally {
84
88
  this._state._exit()
85
89
  }