start-command 0.37.9 → 0.38.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.
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Issue #231: executions get caller-chosen names (`--alias`, `--rename`) and
3
+ * can declare the execution they continue (`--continues`), which `--status`
4
+ * reports and `--upload-log` follows.
5
+ */
6
+ const { describe, it, expect } = require('bun:test');
7
+ const { spawnSync } = require('child_process');
8
+ const fs = require('fs');
9
+ const os = require('os');
10
+ const path = require('path');
11
+ const { parseArgs } = require('../src/lib/args-parser');
12
+ const {
13
+ ExecutionStore,
14
+ ExecutionRecord,
15
+ } = require('../src/lib/execution-store');
16
+ const {
17
+ executionAliases,
18
+ findRecord,
19
+ nameExecution,
20
+ } = require('../src/lib/execution-identity');
21
+ const {
22
+ attachIdentity,
23
+ buildChain,
24
+ identityTextLines,
25
+ prepareUploadLog,
26
+ resolveContinues,
27
+ } = require('../src/lib/execution-chain');
28
+
29
+ const CLI = path.join(__dirname, '../src/bin/cli.js');
30
+ const A = '23100000-0000-4000-8000-00000000000a';
31
+ const B = '23100000-0000-4000-8000-00000000000b';
32
+ const C = '23100000-0000-4000-8000-00000000000c';
33
+
34
+ function sandbox() {
35
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'start-231-'));
36
+ const appFolder = path.join(root, 'app');
37
+ return {
38
+ root,
39
+ store: new ExecutionStore({ appFolder }),
40
+ env: {
41
+ ...process.env,
42
+ START_APP_FOLDER: appFolder,
43
+ START_LOG_DIR: path.join(root, 'logs'),
44
+ START_DISABLE_AUTO_ISSUE: '1',
45
+ START_DISABLE_LOG_UPLOAD: '1',
46
+ START_EXECUTION_ID: '',
47
+ },
48
+ };
49
+ }
50
+
51
+ function run(box, args) {
52
+ return spawnSync(process.execPath, [CLI, ...args], {
53
+ env: box.env,
54
+ encoding: 'utf8',
55
+ timeout: 20000,
56
+ });
57
+ }
58
+
59
+ function record(uuid, options = {}, extra = {}) {
60
+ const r = new ExecutionRecord({ uuid, command: `cmd-${uuid}`, options });
61
+ Object.assign(r, extra);
62
+ return r;
63
+ }
64
+
65
+ describe('issue #231: execution names', () => {
66
+ it('resolves uuid, session name, earlier names, then aliases', () => {
67
+ const records = [
68
+ record(A, { aliases: [B] }),
69
+ record(B, { sessionName: 'live' }),
70
+ record(C, { sessionNameHistory: ['old'], name: 'task' }),
71
+ ];
72
+ expect(findRecord(records, B).uuid).toBe(B);
73
+ expect(findRecord(records, 'live').uuid).toBe(B);
74
+ expect(findRecord(records, 'old').uuid).toBe(C);
75
+ expect(findRecord(records, 'task').uuid).toBe(C);
76
+ expect(findRecord(records, 'missing')).toBeNull();
77
+ expect(findRecord(records, '')).toBeNull();
78
+ });
79
+
80
+ it('--alias adds a name and refuses one used by another execution', () => {
81
+ const { store } = sandbox();
82
+ store.save(record(A, { sessionName: 'screen-a' }));
83
+ store.save(record(B));
84
+ const added = nameExecution(store, 'screen-a', 'task-1');
85
+ expect(added).toEqual({
86
+ success: true,
87
+ output: `Aliased execution ${A}\naliases: [${A}, screen-a, task-1]`,
88
+ });
89
+ expect(store.get('task-1').uuid).toBe(A);
90
+ expect(nameExecution(store, A, 'task-1').output).toStartWith(
91
+ `Unchanged execution ${A}`
92
+ );
93
+ expect(nameExecution(store, B, 'task-1')).toEqual({
94
+ success: false,
95
+ error: `Name "task-1" already refers to execution ${A}`,
96
+ });
97
+ expect(nameExecution(store, B, A).success).toBe(false);
98
+ expect(nameExecution(store, 'nope', 'x').error).toBe(
99
+ 'No execution found with UUID or session name: nope'
100
+ );
101
+ expect(nameExecution(store, A, '-x').success).toBe(false);
102
+ // The backend session name list is left alone (it drives --cleanup).
103
+ expect(store.get(A).options.sessionNameHistory).toBeUndefined();
104
+ });
105
+
106
+ it('--rename sets the display name and keeps the old one resolving', () => {
107
+ const { store } = sandbox();
108
+ store.save(record(A, { sessionName: 'screen-a' }));
109
+ expect(nameExecution(store, A, 'first', { rename: true }).output).toBe(
110
+ `Renamed execution ${A}\naliases: [${A}, screen-a, first]`
111
+ );
112
+ nameExecution(store, 'first', 'second', { rename: true });
113
+ const renamed = store.get('second');
114
+ expect(renamed.options).toMatchObject({
115
+ sessionName: 'screen-a',
116
+ name: 'second',
117
+ aliases: ['first'],
118
+ });
119
+ expect(store.get('first').uuid).toBe(A);
120
+ expect(store.get('screen-a').uuid).toBe(A);
121
+ expect(executionAliases(renamed)).toEqual([
122
+ A,
123
+ 'screen-a',
124
+ 'first',
125
+ 'second',
126
+ ]);
127
+ });
128
+
129
+ it('a UUID-shaped alias resolves like any other name', () => {
130
+ const { store } = sandbox();
131
+ store.save(record(A));
132
+ expect(nameExecution(store, A, C).success).toBe(true);
133
+ expect(store.get(C).uuid).toBe(A);
134
+ });
135
+
136
+ it('parses --alias and --rename with their two arguments', () => {
137
+ expect(parseArgs(['--alias', A, 'task']).wrapperOptions).toMatchObject({
138
+ alias: A,
139
+ newName: 'task',
140
+ });
141
+ expect(parseArgs(['--rename', A, 'task']).wrapperOptions).toMatchObject({
142
+ rename: A,
143
+ newName: 'task',
144
+ });
145
+ expect(() => parseArgs(['--alias', A])).toThrow(
146
+ 'Option --alias requires a UUID or session name and a new name'
147
+ );
148
+ expect(() => parseArgs(['--rename', A, '--status'])).toThrow(
149
+ 'Option --rename requires a UUID or session name and a new name'
150
+ );
151
+ expect(() => parseArgs(['--alias', A, 'x', '--status', A])).toThrow(
152
+ /Cannot combine .* --alias, or --rename in the same invocation/
153
+ );
154
+ });
155
+ });
156
+
157
+ describe('issue #231: continuation chains', () => {
158
+ const chainRecords = () => [
159
+ record(A, {}, { startTime: '2026-01-01T00:00:00.000Z' }),
160
+ record(B, {}, { continues: A, startTime: '2026-01-02T00:00:00.000Z' }),
161
+ record(C, {}, { continues: B, startTime: '2026-01-03T00:00:00.000Z' }),
162
+ ];
163
+
164
+ it('builds the chain from any member, oldest first', () => {
165
+ const records = chainRecords();
166
+ for (const member of records) {
167
+ expect(buildChain(records, member).map((r) => r.uuid)).toEqual([A, B, C]);
168
+ }
169
+ });
170
+
171
+ it('stops at a cycle', () => {
172
+ const records = [
173
+ record(A, {}, { continues: B }),
174
+ record(B, {}, { continues: A }),
175
+ ];
176
+ expect(buildChain(records, records[0]).map((r) => r.uuid)).toEqual([B, A]);
177
+ });
178
+
179
+ it('resolves --continues by any name and refuses unknown or itself', () => {
180
+ const { store } = sandbox();
181
+ store.save(record(A, { sessionName: 'first' }));
182
+ expect(resolveContinues(store, 'first', B)).toBe(A);
183
+ expect(() => resolveContinues(store, 'nope', B)).toThrow(
184
+ 'No execution found with UUID or session name: nope'
185
+ );
186
+ expect(() => resolveContinues(store, A, A)).toThrow(
187
+ `Execution ${A} cannot continue itself`
188
+ );
189
+ expect(() => resolveContinues(null, A, B)).toThrow(
190
+ '--continues requires execution tracking.'
191
+ );
192
+ });
193
+
194
+ it('--status reports aliases and the chain right after uuid', () => {
195
+ const { store } = sandbox();
196
+ chainRecords().forEach((r) => store.save(r));
197
+ nameExecution(store, B, 'task');
198
+ const obj = attachIdentity(store.get(B), store).toObject();
199
+ expect(Object.keys(obj).slice(0, 3)).toEqual(['uuid', 'aliases', 'chain']);
200
+ expect(obj.aliases).toEqual([B, 'task']);
201
+ expect(obj.chain).toEqual([A, B, C]);
202
+ expect(obj.continues).toBe(A);
203
+ expect(identityTextLines(obj)).toEqual([
204
+ `Aliases: ${B}, task`,
205
+ `Continues: ${A}`,
206
+ `Chain: ${A} -> ${B} -> ${C}`,
207
+ ]);
208
+ // A record without names or a chain is reported unchanged.
209
+ const plain = record(C);
210
+ expect(attachIdentity(plain, null)).toBe(plain);
211
+ });
212
+
213
+ it('--upload-log joins the chain logs, oldest first', () => {
214
+ const { store, root } = sandbox();
215
+ chainRecords().forEach((r, i) => {
216
+ r.logPath = path.join(root, `${i}.log`);
217
+ fs.writeFileSync(r.logPath, `log-${i}\n`);
218
+ store.save(r);
219
+ });
220
+ const single = record('23100000-0000-4000-8000-0000000000dd');
221
+ single.logPath = '/x.log';
222
+ store.save(single);
223
+ expect(prepareUploadLog(store, single).path).toBe('/x.log');
224
+
225
+ const prepared = prepareUploadLog(store, store.get(B));
226
+ const text = fs.readFileSync(prepared.path, 'utf8');
227
+ expect(text.indexOf('log-0')).toBeLessThan(text.indexOf('log-1'));
228
+ expect(text.indexOf('log-1')).toBeLessThan(text.indexOf('log-2'));
229
+ expect(text.split('"event":"continuation"').length - 1).toBe(3);
230
+ expect(text).toContain(`"uuid":"${C}","chainIndex":3,"chainLength":3`);
231
+ prepared.cleanup();
232
+ expect(fs.existsSync(prepared.path)).toBe(false);
233
+ });
234
+
235
+ it('parses --continues and keeps it to launches', () => {
236
+ expect(
237
+ parseArgs(['--continues', A, '--', 'echo']).wrapperOptions.continues
238
+ ).toBe(A);
239
+ expect(
240
+ parseArgs([`--continues=${A}`, '--', 'echo']).wrapperOptions.continues
241
+ ).toBe(A);
242
+ expect(() => parseArgs(['--continues', '--', 'echo'])).toThrow(
243
+ 'Option --continues requires a UUID or session name argument'
244
+ );
245
+ expect(() => parseArgs(['--status', A, '--continues', B])).toThrow(
246
+ '--continues option is only valid when launching a command'
247
+ );
248
+ });
249
+ });
250
+
251
+ describe('issue #231: CLI flow', () => {
252
+ if (process.platform === 'win32') {
253
+ return;
254
+ }
255
+
256
+ it('aliases, renames and chains executions end to end', () => {
257
+ const box = sandbox();
258
+ expect(run(box, ['--session-id', A, '--', 'echo', 'one']).status).toBe(0);
259
+ const alias = run(box, ['--alias', A, 'nightly']);
260
+ expect(alias.status).toBe(0);
261
+ expect(alias.stdout).toContain(`Aliased execution ${A}`);
262
+ const rename = run(box, ['--rename', 'nightly', 'nightly-1']);
263
+ expect(rename.stdout).toContain(`Renamed execution ${A}`);
264
+
265
+ const second = run(box, [
266
+ '--session-id',
267
+ B,
268
+ '--continues',
269
+ 'nightly-1',
270
+ '--',
271
+ 'echo',
272
+ 'two',
273
+ ]);
274
+ expect(second.status).toBe(0);
275
+
276
+ const status = run(box, [
277
+ '--status',
278
+ 'nightly-1',
279
+ '--output-format',
280
+ 'json',
281
+ ]);
282
+ const obj = JSON.parse(status.stdout);
283
+ expect(obj.uuid).toBe(A);
284
+ expect(obj.aliases).toEqual([A, 'nightly', 'nightly-1']);
285
+ expect(obj.chain).toEqual([A, B]);
286
+
287
+ const text = run(box, ['--status', B, '--output-format', 'text']);
288
+ expect(text.stdout).toContain(`Continues: ${A}`);
289
+ expect(text.stdout).toContain(`Chain: ${A} -> ${B}`);
290
+
291
+ const missing = run(box, ['--continues', 'nope', '--', 'echo', 'x']);
292
+ expect(missing.status).toBe(1);
293
+ expect(missing.stderr).toContain(
294
+ 'No execution found with UUID or session name: nope'
295
+ );
296
+ expect(missing.stdout).not.toContain('x\n');
297
+ });
298
+ });
@@ -9,6 +9,8 @@ const {
9
9
  formatRecord,
10
10
  enrichDetachedStatus,
11
11
  } = require('../src/lib/status-formatter');
12
+ const { executionAliases } = require('../src/lib/execution-identity');
13
+ const { buildChain, identityTextLines } = require('../src/lib/execution-chain');
12
14
  const fixtures = JSON.parse(
13
15
  readFileSync(resolve(__dirname, '../../parity/fixtures/contracts.json'))
14
16
  );
@@ -80,6 +82,19 @@ describe('shared JavaScript and Rust golden contracts', () => {
80
82
  fixture.text
81
83
  );
82
84
  });
85
+ it('reports execution names and the continuation chain (issue #231)', () => {
86
+ const fixture = fixtures['status-identity'];
87
+ const records = fixture.records.map((r) => ExecutionRecord.fromObject(r));
88
+ const record = records.find((r) => r.uuid === fixture.uuid);
89
+ const aliases = executionAliases(record);
90
+ const chain = buildChain(records, record).map((r) => r.uuid);
91
+ assert.deepEqual(aliases, fixture.aliases);
92
+ assert.deepEqual(chain, fixture.chain);
93
+ assert.deepEqual(
94
+ identityTextLines({ aliases, continues: record.continues, chain }),
95
+ fixture.text
96
+ );
97
+ });
83
98
  for (const fixture of fixtures['execution-record-format']) {
84
99
  it(`preserves the complete execution record format (exit ${fixture.exitCode})`, () => {
85
100
  assert.deepEqual(ExecutionRecord.fromObject(fixture).toObject(), fixture);