start-command 0.33.0 → 0.34.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.
@@ -14,11 +14,17 @@ const {
14
14
  } = require('./output-blocks');
15
15
  const { collectProcessIds } = require('./execution-control');
16
16
  const { getDockerCommand, getDockerSpawnOptions } = require('./docker-cleanup');
17
- const { resolveExitReason, resolveMemoryExhaustion } = require('./exit-reason');
17
+ const {
18
+ describeExitCode,
19
+ resolveExitReason,
20
+ resolveMemoryExhaustion,
21
+ } = require('./exit-reason');
18
22
  const {
19
23
  FATAL_MARKER_TAIL_BYTES,
20
24
  readLogTail,
21
25
  } = require('./isolation-log-utils');
26
+ const { normalizeDockerTimestamp } = require('./docker-post-mortem');
27
+ const { END_TIME_SOURCE } = require('./detached-finalize');
22
28
 
23
29
  /**
24
30
  * Inspect the live state of a detached docker container by name.
@@ -44,7 +50,8 @@ function inspectDockerState(sessionName) {
44
50
  [
45
51
  'inspect',
46
52
  '-f',
47
- '{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}}',
53
+ '{{.State.Running}} {{.State.ExitCode}} {{.State.OOMKilled}} ' +
54
+ '{{.State.StartedAt}} {{.State.FinishedAt}}',
48
55
  sessionName,
49
56
  ],
50
57
  getDockerSpawnOptions({
@@ -56,13 +63,18 @@ function inspectDockerState(sessionName) {
56
63
  if (result.error || result.status !== 0 || !result.stdout) {
57
64
  return null;
58
65
  }
59
- const [runningRaw, exitRaw, oomKilledRaw] = result.stdout.trim().split(/\s+/);
66
+ // Parsed positionally and defensively: an older docker (or a stubbed one)
67
+ // may answer with fewer fields than the template asks for.
68
+ const [runningRaw, exitRaw, oomKilledRaw, startedRaw, finishedRaw] =
69
+ result.stdout.trim().split(/\s+/);
60
70
  const exitCode = Number.parseInt(exitRaw, 10);
61
71
  return {
62
72
  running: runningRaw === 'true',
63
73
  exitCode: Number.isFinite(exitCode) ? exitCode : null,
64
74
  oomKilled:
65
75
  oomKilledRaw === 'true' ? true : oomKilledRaw === 'false' ? false : null,
76
+ startedAt: normalizeDockerTimestamp(startedRaw),
77
+ finishedAt: normalizeDockerTimestamp(finishedRaw),
66
78
  };
67
79
  }
68
80
 
@@ -193,7 +205,50 @@ function isDetachedSessionAlive(record) {
193
205
  * `start` appends itself (issue #150).
194
206
  */
195
207
  const LOG_FOOTER_PATTERN =
196
- /^={10,}[ \t]*\r?\n^Finished:[^\r\n]*\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
208
+ /^={10,}[ \t]*\r?\n^Finished:[ \t]*([^\r\n]*)\r?\n^Exit Code:[ \t]*(-?\d+)[ \t]*(?![^\r\n])/gm;
209
+
210
+ /**
211
+ * Parse the `Finished:` line of the footer into an ISO timestamp.
212
+ *
213
+ * `start` writes it as `YYYY-MM-DD HH:MM:SS[.mmm]` in UTC (both from JS and
214
+ * from the shell snippet). Anything else — a truncated log, a test fixture, a
215
+ * future format — yields null so the caller falls back instead of inventing a
216
+ * timestamp out of an unparseable string.
217
+ *
218
+ * @param {string|null|undefined} value - Raw text after `Finished:`
219
+ * @returns {string|null} ISO timestamp, or null
220
+ */
221
+ function parseFooterTimestamp(value) {
222
+ if (typeof value !== 'string') {
223
+ return null;
224
+ }
225
+ const trimmed = value.trim();
226
+ if (!/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(\.\d+)?Z?$/.test(trimmed)) {
227
+ return null;
228
+ }
229
+ const parsed = Date.parse(`${trimmed.replace(' ', 'T').replace(/Z$/, '')}Z`);
230
+ return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null;
231
+ }
232
+
233
+ /**
234
+ * Read the terminal footer written at the very end of a log.
235
+ * @param {string|null} tail - Tail of the execution log
236
+ * @returns {{exitCode: number|null, finishedAt: string|null}} Footer facts
237
+ */
238
+ function parseFooterFromTail(tail) {
239
+ if (tail === null || tail === undefined) {
240
+ return { exitCode: null, finishedAt: null };
241
+ }
242
+ const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
243
+ if (matches.length === 0) {
244
+ return { exitCode: null, finishedAt: null };
245
+ }
246
+ const last = matches[matches.length - 1];
247
+ return {
248
+ exitCode: parseInt(last[2], 10),
249
+ finishedAt: parseFooterTimestamp(last[1]),
250
+ };
251
+ }
197
252
 
198
253
  /**
199
254
  * Read the terminal exit code from the anchored footer at the end of a log.
@@ -207,11 +262,7 @@ function parseExitCodeFromTail(tail) {
207
262
  if (tail === null || tail === undefined) {
208
263
  return null;
209
264
  }
210
- const matches = [...tail.matchAll(LOG_FOOTER_PATTERN)];
211
- if (matches.length === 0) {
212
- return null;
213
- }
214
- return parseInt(matches[matches.length - 1][1], 10);
265
+ return parseFooterFromTail(tail).exitCode;
215
266
  }
216
267
 
217
268
  function readExitCodeFromLog(logPath) {
@@ -230,8 +281,43 @@ function readExitCodeFromLog(logPath) {
230
281
  * @param {string|null} logTail - Tail of the execution log (already read once)
231
282
  * @returns {Object} Possibly updated execution record
232
283
  */
284
+ /**
285
+ * Decide the moment a finished execution ended, and say where that moment came
286
+ * from (issue #170.2).
287
+ *
288
+ * Precedence: the container's own `State.FinishedAt`, then the `Finished:` line
289
+ * of the footer `start` wrote itself, and only then the time the end was
290
+ * observed — which is explicitly marked as such, so a consumer can tell a real
291
+ * finish time from a query-time observation.
292
+ *
293
+ * @param {Object} enriched - Record copy being enriched (mutated)
294
+ * @param {Object|null} dockerState - Inspected docker state, when available
295
+ * @param {string|null} footerFinishedAt - Footer timestamp, when parseable
296
+ * @returns {void}
297
+ */
298
+ function applyEndTime(enriched, dockerState, footerFinishedAt) {
299
+ if (enriched.endTime) {
300
+ return;
301
+ }
302
+ const dockerFinishedAt = dockerState ? dockerState.finishedAt : null;
303
+ if (dockerFinishedAt) {
304
+ enriched.endTime = new Date(dockerFinishedAt).toISOString();
305
+ enriched.endTimeSource = END_TIME_SOURCE.DOCKER_FINISHED_AT;
306
+ return;
307
+ }
308
+ if (footerFinishedAt) {
309
+ enriched.endTime = footerFinishedAt;
310
+ enriched.endTimeSource = END_TIME_SOURCE.LOG_FOOTER;
311
+ return;
312
+ }
313
+ enriched.endTime = new Date().toISOString();
314
+ enriched.endTimeSource = END_TIME_SOURCE.OBSERVED_AT;
315
+ enriched.observedAt = enriched.endTime;
316
+ }
317
+
233
318
  function resolveDetachedStatus(record, logTail) {
234
- const footerExit = parseExitCodeFromTail(logTail);
319
+ const footer = parseFooterFromTail(logTail);
320
+ const footerExit = footer.exitCode;
235
321
  const dockerState = isDetachedDockerRecord(record)
236
322
  ? readDockerState(record)
237
323
  : null;
@@ -268,9 +354,7 @@ function resolveDetachedStatus(record, logTail) {
268
354
  const enriched = cloneRecord();
269
355
  enriched.status = 'executed';
270
356
  enriched.exitCode = footerExit;
271
- if (!enriched.endTime) {
272
- enriched.endTime = new Date().toISOString();
273
- }
357
+ applyEndTime(enriched, dockerState, footer.finishedAt);
274
358
  return enriched;
275
359
  }
276
360
  if (oomKilled === true) {
@@ -282,9 +366,7 @@ function resolveDetachedStatus(record, logTail) {
282
366
  const enriched = cloneRecord();
283
367
  enriched.status = 'executed';
284
368
  enriched.exitCode = 137;
285
- if (!enriched.endTime) {
286
- enriched.endTime = new Date().toISOString();
287
- }
369
+ applyEndTime(enriched, dockerState, footer.finishedAt);
288
370
  return enriched;
289
371
  }
290
372
  }
@@ -324,9 +406,7 @@ function resolveDetachedStatus(record, logTail) {
324
406
  footerExit ??
325
407
  (oomKilled === true ? 137 : -1);
326
408
  }
327
- if (!enriched.endTime) {
328
- enriched.endTime = new Date().toISOString();
329
- }
409
+ applyEndTime(enriched, dockerState, footer.finishedAt);
330
410
  }
331
411
 
332
412
  return enriched;
@@ -562,7 +642,7 @@ function formatRecordAsText(record) {
562
642
  `UUID: ${obj.uuid}`,
563
643
  `Status: ${obj.status}`,
564
644
  `Command: ${obj.command}`,
565
- `Exit Code: ${obj.exitCode !== null ? obj.exitCode : 'N/A'}`,
645
+ `Exit Code: ${obj.exitCode !== null ? describeExitCode(obj.exitCode).text : 'N/A'}`,
566
646
  ...(obj.oomKilled !== undefined
567
647
  ? [`OOM Killed: ${obj.oomKilled}`]
568
648
  : []),
@@ -584,10 +664,19 @@ function formatRecordAsText(record) {
584
664
  if (obj.currentTime) {
585
665
  lines.push(`Current Time: ${obj.currentTime}`);
586
666
  }
587
- lines.push(
588
- `End Time: ${obj.endTime || 'N/A'}`,
589
- `Log Path: ${obj.logPath}`
590
- );
667
+ lines.push(`End Time: ${obj.endTime || 'N/A'}`);
668
+ if (obj.endTimeSource) {
669
+ // Where `End Time` came from: a real finish time, or merely when `start`
670
+ // noticed the execution was over (issue #170.2).
671
+ lines.push(`End Time Source: ${obj.endTimeSource}`);
672
+ }
673
+ if (obj.containerStartedAt) {
674
+ lines.push(`Container Started: ${obj.containerStartedAt}`);
675
+ }
676
+ if (obj.staleDetectedAt) {
677
+ lines.push(`Stale Detected At: ${obj.staleDetectedAt}`);
678
+ }
679
+ lines.push(`Log Path: ${obj.logPath}`);
591
680
 
592
681
  // Format options as nested list instead of JSON
593
682
  const optionEntries = Object.entries(obj.options || {}).filter(
@@ -778,6 +867,8 @@ module.exports = {
778
867
  listExecutions,
779
868
  isDetachedSessionAlive,
780
869
  enrichDetachedStatus,
870
+ parseFooterFromTail,
871
+ parseFooterTimestamp,
781
872
  readExitCodeFromLog,
782
873
  readLogTail,
783
874
  attachExitReason,
@@ -573,11 +573,14 @@ describe('ExecutionStore cleanupStale', () => {
573
573
  const result = store.cleanupStale({ dryRun: false });
574
574
  expect(result.cleaned).toBe(1);
575
575
 
576
- // Verify it's now "executed" with exit code -1
576
+ // Verify it's now "executed" with exit code -1. The finish time stays
577
+ // unknown — cleanup records *when the loss was detected* instead of
578
+ // fabricating a finish time it cannot know (issue #170.3).
577
579
  retrieved = store.get(record.uuid);
578
580
  expect(retrieved.status).toBe(ExecutionStatus.EXECUTED);
579
581
  expect(retrieved.exitCode).toBe(-1);
580
- expect(retrieved.endTime).toBeTruthy();
582
+ expect(retrieved.endTime).toBeNull();
583
+ expect(retrieved.staleDetectedAt).toBeTruthy();
581
584
  });
582
585
 
583
586
  it('should handle custom max age', () => {
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Regression tests for issue #170: detached Docker executions never persist a
3
+ * terminal state, and `--status` fabricates `endTime` with `new Date()`.
4
+ *
5
+ * The three defects covered here:
6
+ * 170.1 the detached completion watcher must write the terminal state back
7
+ * into the store instead of leaving the record `executing` forever;
8
+ * 170.2 `--status` must derive `endTime` from a real clock
9
+ * (`State.FinishedAt` -> log footer `Finished:`) and mark the
10
+ * provenance via `endTimeSource` when it falls back to observation time;
11
+ * 170.3 `cleanupStale()` must record `staleDetectedAt`, not a fabricated
12
+ * finish time.
13
+ */
14
+
15
+ const { describe, it, expect, afterEach } = require('bun:test');
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+
20
+ const {
21
+ ExecutionStore,
22
+ ExecutionRecord,
23
+ ExecutionStatus,
24
+ } = require('../src/lib/execution-store');
25
+ const { resolveDetachedStatus } = require('../src/lib/status-formatter');
26
+ const {
27
+ buildDetachedDockerCompletionScript,
28
+ } = require('../src/lib/docker-cleanup');
29
+ const { finalizeDetachedExecution } = require('../src/lib/detached-finalize');
30
+
31
+ const tempDirs = [];
32
+
33
+ function makeTempDir(prefix) {
34
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
35
+ tempDirs.push(dir);
36
+ return dir;
37
+ }
38
+
39
+ function createExecutable(filePath, content) {
40
+ fs.writeFileSync(filePath, content, 'utf8');
41
+ fs.chmodSync(filePath, 0o755);
42
+ }
43
+
44
+ /**
45
+ * A fake `docker` that answers `inspect` with a fixed state line, whatever the
46
+ * `-f` template asks for. The template is echoed verbatim by the caller, so the
47
+ * test controls exactly how many fields the formatter has to cope with.
48
+ *
49
+ * Windows gets a `.cmd` batch stub: `spawnSync()` there cannot execute an
50
+ * extensionless `#!/bin/sh` script, and `--status` resolves the docker facts
51
+ * with a plain `spawnSync()` on every platform.
52
+ */
53
+ function withFakeDockerInspect(stateLine, fn) {
54
+ const fakeBin = makeTempDir('fake-docker-170-');
55
+ const isWindows = process.platform === 'win32';
56
+ const dockerPath = path.join(fakeBin, isWindows ? 'docker.cmd' : 'docker');
57
+ createExecutable(
58
+ dockerPath,
59
+ isWindows
60
+ ? [
61
+ '@echo off',
62
+ 'if not "%1"=="inspect" exit /b 1',
63
+ `echo ${stateLine}`,
64
+ 'exit /b 0',
65
+ '',
66
+ ].join('\r\n')
67
+ : [
68
+ '#!/bin/sh',
69
+ '[ "$1" = "inspect" ] || exit 1',
70
+ `echo "${stateLine}"`,
71
+ '',
72
+ ].join('\n')
73
+ );
74
+ const originalPath = process.env.PATH;
75
+ const originalDockerBin = process.env.START_DOCKER_BIN;
76
+ process.env.PATH = `${fakeBin}${path.delimiter}${originalPath || ''}`;
77
+ process.env.START_DOCKER_BIN = dockerPath;
78
+ try {
79
+ return fn();
80
+ } finally {
81
+ process.env.PATH = originalPath;
82
+ if (originalDockerBin === undefined) {
83
+ delete process.env.START_DOCKER_BIN;
84
+ } else {
85
+ process.env.START_DOCKER_BIN = originalDockerBin;
86
+ }
87
+ }
88
+ }
89
+
90
+ function withFakeDockerMissingContainer(fn) {
91
+ const fakeBin = makeTempDir('fake-docker-gone-170-');
92
+ const dockerPath = path.join(fakeBin, 'docker');
93
+ createExecutable(dockerPath, ['#!/bin/sh', 'exit 1', ''].join('\n'));
94
+ const originalPath = process.env.PATH;
95
+ const originalDockerBin = process.env.START_DOCKER_BIN;
96
+ process.env.PATH = `${fakeBin}${path.delimiter}${originalPath || ''}`;
97
+ process.env.START_DOCKER_BIN = dockerPath;
98
+ try {
99
+ return fn();
100
+ } finally {
101
+ process.env.PATH = originalPath;
102
+ if (originalDockerBin === undefined) {
103
+ delete process.env.START_DOCKER_BIN;
104
+ } else {
105
+ process.env.START_DOCKER_BIN = originalDockerBin;
106
+ }
107
+ }
108
+ }
109
+
110
+ function detachedDockerRecord(overrides = {}) {
111
+ return new ExecutionRecord({
112
+ pid: null,
113
+ status: ExecutionStatus.EXECUTING,
114
+ exitCode: null,
115
+ command: 'sleep 100',
116
+ startTime: '2026-09-15T22:21:40.000Z',
117
+ endTime: null,
118
+ options: {
119
+ isolated: 'docker',
120
+ isolationMode: 'detached',
121
+ sessionName: 'start-command-170',
122
+ },
123
+ ...overrides,
124
+ });
125
+ }
126
+
127
+ afterEach(() => {
128
+ while (tempDirs.length > 0) {
129
+ const dir = tempDirs.pop();
130
+ fs.rmSync(dir, { recursive: true, force: true });
131
+ }
132
+ });
133
+
134
+ describe('issue #170.2: endTime is never fabricated silently', () => {
135
+ it('derives endTime from docker State.FinishedAt for a stopped container', () => {
136
+ const record = detachedDockerRecord();
137
+ const enriched = withFakeDockerInspect(
138
+ 'false 137 false 2026-09-15T22:21:40.942007645Z 2026-09-15T22:21:46.740817278Z',
139
+ () => resolveDetachedStatus(record, null)
140
+ );
141
+
142
+ expect(enriched.status).toBe('executed');
143
+ expect(enriched.exitCode).toBe(137);
144
+ expect(enriched.endTimeSource).toBe('docker-finished-at');
145
+ expect(new Date(enriched.endTime).toISOString()).toBe(
146
+ '2026-09-15T22:21:46.740Z'
147
+ );
148
+ });
149
+
150
+ it('falls back to the log footer Finished: line when docker is gone', () => {
151
+ const record = detachedDockerRecord();
152
+ const tail = [
153
+ 'output line',
154
+ '='.repeat(50),
155
+ 'Finished: 2026-09-15 22:21:46.740',
156
+ 'Exit Code: 137',
157
+ '',
158
+ ].join('\n');
159
+
160
+ const enriched = withFakeDockerMissingContainer(() =>
161
+ resolveDetachedStatus(record, tail)
162
+ );
163
+
164
+ expect(enriched.status).toBe('executed');
165
+ expect(enriched.exitCode).toBe(137);
166
+ expect(enriched.endTimeSource).toBe('log-footer');
167
+ expect(new Date(enriched.endTime).toISOString()).toBe(
168
+ '2026-09-15T22:21:46.740Z'
169
+ );
170
+ });
171
+
172
+ it('marks the observation-time fallback instead of pretending it is a finish time', () => {
173
+ const record = detachedDockerRecord();
174
+ const tail = [
175
+ '='.repeat(50),
176
+ 'Finished: not-a-timestamp',
177
+ 'Exit Code: 3',
178
+ '',
179
+ ].join('\n');
180
+
181
+ const enriched = withFakeDockerMissingContainer(() =>
182
+ resolveDetachedStatus(record, tail)
183
+ );
184
+
185
+ expect(enriched.status).toBe('executed');
186
+ expect(enriched.exitCode).toBe(3);
187
+ expect(enriched.endTimeSource).toBe('observed-at');
188
+ expect(enriched.observedAt).toBe(enriched.endTime);
189
+ });
190
+
191
+ it('keeps an already recorded endTime and marks it as persisted', () => {
192
+ const record = detachedDockerRecord({
193
+ status: ExecutionStatus.EXECUTED,
194
+ exitCode: 0,
195
+ endTime: '2026-09-15T22:21:46.740Z',
196
+ endTimeSource: 'docker-finished-at',
197
+ });
198
+
199
+ const enriched = withFakeDockerInspect('false 0 false', () =>
200
+ resolveDetachedStatus(record, null)
201
+ );
202
+
203
+ expect(enriched.endTime).toBe('2026-09-15T22:21:46.740Z');
204
+ expect(enriched.endTimeSource).toBe('docker-finished-at');
205
+ });
206
+ });
207
+
208
+ describe('issue #170.3: cleanupStale records the detection time', () => {
209
+ it('sets staleDetectedAt and leaves endTime unknown', () => {
210
+ const appFolder = makeTempDir('stale-170-');
211
+ const store = new ExecutionStore({ appFolder, useLinks: false });
212
+
213
+ const record = new ExecutionRecord({
214
+ pid: 999999,
215
+ status: ExecutionStatus.EXECUTING,
216
+ command: 'sleep 1000',
217
+ startTime: new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(),
218
+ });
219
+ store.save(record);
220
+
221
+ const result = store.cleanupStale({ maxAge: 1000 });
222
+ expect(result.cleaned).toBe(1);
223
+
224
+ const cleaned = store.get(record.uuid);
225
+ expect(cleaned.status).toBe(ExecutionStatus.EXECUTED);
226
+ expect(cleaned.exitCode).toBe(-1);
227
+ expect(cleaned.endTime).toBeNull();
228
+ expect(typeof cleaned.staleDetectedAt).toBe('string');
229
+ expect(Number.isNaN(Date.parse(cleaned.staleDetectedAt))).toBe(false);
230
+ });
231
+ });
232
+
233
+ describe('issue #170.1: the detached watcher persists the terminal state', () => {
234
+ it('invokes the finalizer with the inspected docker facts', () => {
235
+ const script = buildDetachedDockerCompletionScript(
236
+ 'start-command-170',
237
+ 'default',
238
+ '/tmp/start-command-170.log',
239
+ 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
240
+ );
241
+
242
+ expect(script).toContain('detached-finalize');
243
+ expect(script).toContain('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee');
244
+ expect(script).toContain('$__start_command_exit');
245
+ expect(script).toContain('$__start_command_finished');
246
+ });
247
+
248
+ it('stays backward compatible when no execution id is supplied', () => {
249
+ const script = buildDetachedDockerCompletionScript(
250
+ 'start-command-170',
251
+ 'default',
252
+ '/tmp/start-command-170.log'
253
+ );
254
+ expect(script).not.toContain('detached-finalize');
255
+ });
256
+
257
+ it('writes status, exitCode, endTime and provenance into the store', () => {
258
+ const appFolder = makeTempDir('finalize-170-');
259
+ const store = new ExecutionStore({ appFolder, useLinks: false });
260
+ const record = detachedDockerRecord();
261
+ store.save(record);
262
+
263
+ const outcome = finalizeDetachedExecution({
264
+ store,
265
+ executionId: record.uuid,
266
+ exitCode: 137,
267
+ oomKilled: false,
268
+ startedAt: '2026-09-15T22:21:40.942007645Z',
269
+ finishedAt: '2026-09-15T22:21:46.740817278Z',
270
+ containerError: '',
271
+ });
272
+
273
+ expect(outcome.updated).toBe(true);
274
+
275
+ const stored = store.get(record.uuid);
276
+ expect(stored.status).toBe(ExecutionStatus.EXECUTED);
277
+ expect(stored.exitCode).toBe(137);
278
+ expect(stored.oomKilled).toBe(false);
279
+ expect(stored.endTimeSource).toBe('docker-finished-at');
280
+ expect(new Date(stored.endTime).toISOString()).toBe(
281
+ '2026-09-15T22:21:46.740Z'
282
+ );
283
+ expect(stored.exitReason).toContain('SIGKILL');
284
+ });
285
+
286
+ it('falls back to observation time when docker reports no finish time', () => {
287
+ const appFolder = makeTempDir('finalize-170-zero-');
288
+ const store = new ExecutionStore({ appFolder, useLinks: false });
289
+ const record = detachedDockerRecord();
290
+ store.save(record);
291
+
292
+ finalizeDetachedExecution({
293
+ store,
294
+ executionId: record.uuid,
295
+ exitCode: 0,
296
+ oomKilled: false,
297
+ startedAt: 'unknown',
298
+ finishedAt: '0001-01-01T00:00:00Z',
299
+ containerError: '',
300
+ });
301
+
302
+ const stored = store.get(record.uuid);
303
+ expect(stored.status).toBe(ExecutionStatus.EXECUTED);
304
+ expect(stored.exitCode).toBe(0);
305
+ expect(stored.endTimeSource).toBe('observed-at');
306
+ expect(stored.observedAt).toBe(stored.endTime);
307
+ expect(new Date(stored.endTime).getUTCFullYear()).toBeGreaterThan(2000);
308
+ });
309
+
310
+ it('never fails when the record is gone', () => {
311
+ const appFolder = makeTempDir('finalize-170-missing-');
312
+ const store = new ExecutionStore({ appFolder, useLinks: false });
313
+ const outcome = finalizeDetachedExecution({
314
+ store,
315
+ executionId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
316
+ exitCode: 1,
317
+ });
318
+ expect(outcome.updated).toBe(false);
319
+ expect(outcome.reason).toBe('record-not-found');
320
+ });
321
+ });