@remits/remits-cli 0.1.153 → 0.1.155

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/index.js CHANGED
@@ -5,17 +5,17 @@
5
5
 
6
6
  - L22 Runtime Bootstrap And Shared State
7
7
  - L124 Sessions, Account Resolution, And Production Guards
8
- - L621 Local State, Workspaces, And Verification Evidence
9
- - L2659 Account Repos, Guide Sync, And Platform Repo
10
- - L3499 Component Discovery And HTTP Logging
11
- - L4267 Skill Delivery And TOC Resolution
12
- - L4582 Auth And Component Staging
13
- - L5214 Component Summaries, Status, And Sync Gates
14
- - L7515 Branches, Promotion, Commit, And Test Runs
15
- - L9631 Tokens, Tools, Verification, And Config
16
- - L11058 Service Dashboard And WebSocket Listener
17
- - L13557 Agent And Ticket Workflows
18
- - L17692 Help, Auto Update, And Command Dispatch
8
+ - L630 Local State, Workspaces, And Verification Evidence
9
+ - L2668 Account Repos, Guide Sync, And Platform Repo
10
+ - L3508 Component Discovery And HTTP Logging
11
+ - L4276 Skill Delivery And TOC Resolution
12
+ - L4591 Auth And Component Staging
13
+ - L5223 Component Summaries, Status, And Sync Gates
14
+ - L7547 Branches, Promotion, Commit, And Test Runs
15
+ - L9676 Tokens, Tools, Verification, And Config
16
+ - L11103 Service Dashboard And WebSocket Listener
17
+ - L13602 Agent And Ticket Workflows
18
+ - L17778 Help, Auto Update, And Command Dispatch
19
19
  */
20
20
 
21
21
  /*
@@ -136,6 +136,15 @@ function parseArgs(argv) {
136
136
  out._.push(arg);
137
137
  continue;
138
138
  }
139
+ // `B="--data-mode prod --base-url URL"; remits-cli ... $B` reaches us as ONE argument under zsh, which
140
+ // does not word-split an unquoted variable. Parsed as a flag NAME it matched nothing, so the command ran
141
+ // with no host and no lane at all - against whatever the defaults named - and its answer looked like
142
+ // a real one. A flag name never contains whitespace, so say what happened instead.
143
+ if (/\s/.test(arg)) {
144
+ throw new Error('One argument holds several flags: "' + arg + '". Your shell passed them as a single ' +
145
+ 'word (zsh does not split an unquoted $VAR). Write the flags out, or use an array: ' +
146
+ 'B=(--data-mode prod --base-url URL); remits-cli ... "${B[@]}".');
147
+ }
139
148
  const shortName = arg.startsWith('--') ? null : arg.slice(1);
140
149
  const key = shortName ? (SHORT_FLAG_ALIASES[shortName] || shortName) : arg.slice(2);
141
150
  const next = argv[i + 1];
@@ -6423,6 +6432,16 @@ async function syncComponentsCommand(rawFlags) {
6423
6432
  const dryRun = flagEnabled(flags['dry-run']) || flagEnabled(flags.dryRun);
6424
6433
  const timeoutMs = parsePositiveInt(flags['timeout-ms'] || flags.timeoutMs, 60000);
6425
6434
  const api = buildAxios(baseUrl, session.token, timeoutMs);
6435
+ const observeOperation = (operationId) => loggedPost(api, cwd, '/cli/components', {
6436
+ token: session.token, accountId, branchName, workspace, dataMode, mode: 'sync-status', operationId
6437
+ }).then((r) => r.data);
6438
+ const operationOptions = { waitMs: parsePositiveInt(flags['wait-timeout-ms'], 600000) };
6439
+ if (flags.resume) {
6440
+ const result = await require('./lib/sync-operation').observeSync(observeOperation, String(flags.resume), operationOptions);
6441
+ console.log(JSON.stringify(result, null, 2));
6442
+ if (result.success === false) process.exitCode = 1;
6443
+ return result;
6444
+ }
6426
6445
  // Aligned for the same reason a stage is: the server's plan names a `new_` component by its sidecar
6427
6446
  // `name:`, and the changed set would otherwise name it by its filename and the gate would refuse it.
6428
6447
  const changedFromWorkingTree = alignChangedSetWithComponents(changedComponentsFromWorkingTree(cwd), collectComponentsForIdentity(cwd));
@@ -6627,7 +6646,20 @@ async function syncComponentsCommand(rawFlags) {
6627
6646
 
6628
6647
  // Through the same refusal-aware door as `stage`: the landing lease and the account's commit rules both
6629
6648
  // answer 409 with the sentence to act on, and that sentence must reach the caller intact.
6630
- const response = await stageOrRefuse(api, cwd, syncPayload);
6649
+ if (!dryRun && !namesOnly) {
6650
+ syncPayload.operationId = crypto.randomUUID();
6651
+ const receiptDir = path.join(localStatePaths(cwd).actorDir, 'sync-operations');
6652
+ fs.mkdirSync(receiptDir, { recursive: true });
6653
+ fs.writeFileSync(path.join(receiptDir, syncPayload.operationId + '.json'), JSON.stringify({
6654
+ operationId: syncPayload.operationId, baseUrl: normalizeBaseUrl(baseUrl), accountId,
6655
+ branchName, workspace, dataMode, created: new Date().toISOString()
6656
+ }, null, 2), { mode: 0o600 });
6657
+ console.error('Sync operation: ' + syncPayload.operationId);
6658
+ }
6659
+ const response = syncPayload.operationId
6660
+ ? await require('./lib/sync-operation').submitSync(
6661
+ (payload) => stageOrRefuse(api, cwd, payload), observeOperation, syncPayload, operationOptions)
6662
+ : await stageOrRefuse(api, cwd, syncPayload);
6631
6663
 
6632
6664
  if (!response.success) {
6633
6665
  throw new Error(response.message || 'Server sync failed');
@@ -8344,8 +8376,9 @@ async function waitForStatus(api, cwd, accountId, branchName, taskId, token, dat
8344
8376
  }
8345
8377
  }
8346
8378
 
8379
+ // `incomplete`: a durable checkpoint nothing replaced — the run stopped without a final result (ticket 67537).
8347
8380
  function testStatusIsTerminal(status = {}) {
8348
- return ['completed', 'failed', 'interrupted'].includes(String(status.status || '').toLowerCase());
8381
+ return ['completed', 'failed', 'interrupted', 'incomplete'].includes(String(status.status || '').toLowerCase());
8349
8382
  }
8350
8383
 
8351
8384
  function testEvidenceCategories(status = {}, selectedNames = []) {
@@ -8477,6 +8510,15 @@ function detectNondeterministicTestRun(priorPackets, currentStatus, currentProve
8477
8510
  return null;
8478
8511
  }
8479
8512
 
8513
+ // The selection a run was ASKED for. Executed case names are not a selection: a full-suite run executes every
8514
+ // case, and reporting those names as `selectedCases` made it read as a run filtered down to them (ticket 67106).
8515
+ // Results recorded before the suite reported its selection fall back to the executed names, the old reading.
8516
+ function requestedTestSelection(status = {}) {
8517
+ const result = status.result || {};
8518
+ if (Array.isArray(result.selectedTestNames)) return result.selectedTestNames.filter(Boolean);
8519
+ return Array.isArray(result.tests) ? result.tests.map((t) => t && t.name).filter(Boolean) : [];
8520
+ }
8521
+
8480
8522
  async function appendTerminalTestRunEvidence(options = {}) {
8481
8523
  const {
8482
8524
  api,
@@ -8532,6 +8574,7 @@ async function appendTerminalTestRunEvidence(options = {}) {
8532
8574
  testId: status.test && status.test.id,
8533
8575
  testName: status.test && status.test.name,
8534
8576
  selectedCases: selectedNames,
8577
+ declaredCases: status.result && Array.isArray(status.result.declaredTestNames) ? status.result.declaredTestNames : undefined,
8535
8578
  passed: status.result && status.result.passed,
8536
8579
  failed: status.result && status.result.failed,
8537
8580
  tests: status.result && status.result.tests,
@@ -9420,7 +9463,7 @@ async function testStatusCommand(flags) {
9420
9463
  flags: statusVerification.evidenceFlags,
9421
9464
  status,
9422
9465
  testRef: status.test && (status.test.name || status.test.id) || taskId,
9423
- selectedNames: status.result && Array.isArray(status.result.tests) ? status.result.tests.map((t) => t && t.name).filter(Boolean) : [],
9466
+ selectedNames: requestedTestSelection(status),
9424
9467
  dataMode,
9425
9468
  dataModeSource,
9426
9469
  branchName,
@@ -9440,8 +9483,10 @@ async function testStatusCommand(flags) {
9440
9483
  testRunHeadlineLines(status, {
9441
9484
  taskId,
9442
9485
  source: status.durable
9443
- ? 'durable run record (the live status has expired; this run is final)'
9444
- : 'live run status',
9486
+ ? (status.status === 'incomplete'
9487
+ ? 'durable checkpoint (the run never recorded a final result; these are the cases it finished)'
9488
+ : 'durable run record (the live status has expired; this run is final)')
9489
+ : (status.partialResult ? 'live run status (the run failed outside its cases; these are the cases it finished)' : 'live run status'),
9445
9490
  dataMode,
9446
9491
  // A durable run reports the lane it RAN in. When that differs from the lane this command asked for,
9447
9492
  // say so: auditing prod activity and being handed a test-lane run is correct and reads as if it were not.
@@ -9460,7 +9505,7 @@ async function testStatusCommand(flags) {
9460
9505
  flags: statusVerification.evidenceFlags,
9461
9506
  status,
9462
9507
  testRef: status.test && (status.test.name || status.test.id) || taskId,
9463
- selectedNames: status.result && Array.isArray(status.result.tests) ? status.result.tests.map((t) => t && t.name).filter(Boolean) : [],
9508
+ selectedNames: requestedTestSelection(status),
9464
9509
  dataMode,
9465
9510
  dataModeSource,
9466
9511
  branchName,
@@ -9476,7 +9521,7 @@ async function testStatusCommand(flags) {
9476
9521
  });
9477
9522
  }
9478
9523
  }
9479
- if (status.status === 'failed' || status.status === 'interrupted' || (status.result && status.result.failed > 0)) {
9524
+ if (status.status === 'failed' || status.status === 'interrupted' || status.status === 'incomplete' || (status.result && status.result.failed > 0)) {
9480
9525
  process.exitCode = 1;
9481
9526
  }
9482
9527
  return status;
@@ -14158,23 +14203,50 @@ function resolveAgentId(flags, options = {}) {
14158
14203
  if (explicit) {
14159
14204
  return String(explicit).trim();
14160
14205
  }
14206
+ const own = ownLocalAgent(flags);
14207
+ if (own) {
14208
+ return own.agentId;
14209
+ }
14210
+ if (options.optional) return null;
14161
14211
  const local = listLocalAgents();
14162
14212
  if (!local.length) {
14163
- if (options.optional) return null;
14164
14213
  throw new Error('No local agent is registered in this session. Run: remits-cli agent register');
14165
14214
  }
14166
- const cwd = process.cwd();
14167
- const inCwd = local.filter((entry) => entry.cwd === cwd);
14168
- const candidates = inCwd.length ? inCwd : local;
14169
- if (candidates.length === 1) {
14170
- return candidates[0].agentId;
14171
- }
14172
14215
  throw new Error(
14173
- 'Several local agents are registered. Pass --agent-id (or set REMITS_AGENT_ID). Candidates:\n' +
14174
- candidates.map((entry) => ' ' + entry.agentId + ' ' + (entry.label || '') + ' ' + entry.cwd).join('\n')
14216
+ 'No agent is registered for THIS terminal session; the local agents below belong to other sessions. ' +
14217
+ 'Pass --agent-id (or set REMITS_AGENT_ID) to act as one of them on purpose. Candidates:\n' +
14218
+ local.map((entry) => ' ' + entry.agentId + ' ' + (entry.label || '') + ' ' + entry.cwd).join('\n')
14175
14219
  );
14176
14220
  }
14177
14221
 
14222
+ /**
14223
+ * The local agent registration THIS process belongs to — never merely "an agent on this machine".
14224
+ *
14225
+ * `~/.remits-cli/agents.json` is machine-wide: every live terminal that ran `agent register`/`serve` has
14226
+ * an entry there. Commands used to take the first entry (the most recently registered) or "the
14227
+ * only one in this cwd", so a Codex session registered against localhost silently pointed a Claude
14228
+ * session's `ticket` verbs at localhost — where the ticket does not exist, answered "No accessible
14229
+ * support ticket" — and `agent release`/`agent work` could act as the other session's agent.
14230
+ *
14231
+ * Ownership is explicit (`--agent-id` / `REMITS_AGENT_ID`, which a supervised worker always carries) or
14232
+ * the registration anchored to this terminal's own agent/shell process — the same anchor `agent
14233
+ * register` records. Anything else is somebody else's, and the caller falls back to its flags and the
14234
+ * CLI default rather than inheriting a stranger's host, lane and identity.
14235
+ */
14236
+ function ownLocalAgent(flags, knownAnchor) {
14237
+ const local = listLocalAgents();
14238
+ if (!local.length) return null;
14239
+ const explicit = textFlag(flags && (flags['agent-id'] || flags.agentId)) || textFlag(process.env.REMITS_AGENT_ID);
14240
+ if (explicit) {
14241
+ return local.find((entry) => entry.agentId === explicit) || null;
14242
+ }
14243
+ const anchor = knownAnchor || resolveAgentAnchorPid();
14244
+ if (!anchor || !anchor.pid) return null;
14245
+ const mine = local.filter((entry) => entry.anchorPid === anchor.pid && entry.anchorSource === anchor.source);
14246
+ const cwd = process.cwd();
14247
+ return mine.find((entry) => entry.cwd === cwd) || mine[0] || null;
14248
+ }
14249
+
14178
14250
  /**
14179
14251
  * The host and lane a support agent registers against when it is told nothing.
14180
14252
  *
@@ -14304,10 +14376,10 @@ async function agentRegisterCommand(flags, options = {}) {
14304
14376
  const explicitPersona = textFlag(flags.persona) || textFlag(options.serve && options.serve.persona) || textFlag(process.env.REMITS_AGENT_PERSONA);
14305
14377
  // Adopt a previous registration only for THIS terminal. Matching on (cwd, anchorPid) alone was enough
14306
14378
  // to let an unrelated record be adopted whenever two registrations shared an anchor.
14307
- const existingId = explicitId ||
14308
- (listLocalAgents().find((entry) =>
14309
- entry.cwd === cwd && entry.anchorPid === anchor.pid && entry.anchorSource === anchor.source) || {}).agentId ||
14310
- null;
14379
+ // `ownLocalAgent` is the one definition of "this terminal's registration"; re-registering additionally
14380
+ // requires the same checkout, so a second repo in one terminal registers its own agent.
14381
+ const own = explicitId ? null : ownLocalAgent(flags, anchor);
14382
+ const existingId = explicitId || (own && own.cwd === cwd ? own.agentId : null);
14311
14383
 
14312
14384
  const api = buildAxios(baseUrl, session.token, 15000);
14313
14385
  const payload = {
@@ -15497,6 +15569,20 @@ function printTicketPolicy(response) {
15497
15569
  }
15498
15570
  }
15499
15571
 
15572
+ /**
15573
+ * A ticket refusal names the platform and data lane that refused it.
15574
+ *
15575
+ * Ticket ids are global, but the record lives on ONE platform. A refusal from the wrong host - a local
15576
+ * bootRun, a test lane - reads exactly like a refusal from the right one ("No accessible support ticket
15577
+ * 67491"), and a caller who cannot see which host answered keeps retrying the wrong one.
15578
+ */
15579
+ function ticketRefusalLine(context, message) {
15580
+ const where = context && context.baseUrl
15581
+ ? ' by ' + context.baseUrl + (context.dataMode ? ' (' + context.dataMode + ' lane)' : '')
15582
+ : '';
15583
+ return 'Refused' + where + ': ' + message;
15584
+ }
15585
+
15500
15586
  async function ticketCommand(flags, subcommand) {
15501
15587
  const action = String(subcommand || 'read').toLowerCase();
15502
15588
 
@@ -15520,7 +15606,7 @@ async function ticketCommand(flags, subcommand) {
15520
15606
  throw new Error('A --ticket ID is required (or set REMITS_SUPPORT_TICKET_ID).');
15521
15607
  }
15522
15608
 
15523
- const local = listLocalAgents()[0] || null;
15609
+ const local = ownLocalAgent(flags);
15524
15610
  const context = agentSessionFor(flags, local);
15525
15611
 
15526
15612
  // Routing lives on its own endpoint (the operator surface the dashboard and any Embeddable share),
@@ -15536,7 +15622,7 @@ async function ticketCommand(flags, subcommand) {
15536
15622
  .then((r) => r.data)
15537
15623
  .catch((err) => (err && err.response && err.response.data) || null);
15538
15624
  if (!result || result.success === false) {
15539
- console.error('Refused: ' + ((result && result.message) || 'routing failed'));
15625
+ console.error(ticketRefusalLine(context, (result && result.message) || 'routing failed'));
15540
15626
  process.exitCode = 1;
15541
15627
  return;
15542
15628
  }
@@ -15632,7 +15718,7 @@ async function ticketCommand(flags, subcommand) {
15632
15718
  // reach them intact rather than as an HTTP status.
15633
15719
  const body = err && err.response && err.response.data;
15634
15720
  if (body && body.message) {
15635
- console.error('Refused: ' + body.message);
15721
+ console.error(ticketRefusalLine(context, body.message));
15636
15722
  process.exitCode = 1;
15637
15723
  return;
15638
15724
  }
@@ -15973,7 +16059,7 @@ function printTicketQueueRows(tickets) {
15973
16059
  }
15974
16060
 
15975
16061
  async function ticketGlobalQueueCommand(flags) {
15976
- const local = listLocalAgents()[0] || null;
16062
+ const local = ownLocalAgent(flags);
15977
16063
  const context = agentSessionFor(flags, local);
15978
16064
  const entries = globalTicketQueueAccountEntries(flags, context);
15979
16065
  if (!entries.length) {
@@ -16072,7 +16158,7 @@ async function ticketQueueCommand(flags) {
16072
16158
  return ticketGlobalQueueCommand(flags);
16073
16159
  }
16074
16160
 
16075
- const local = listLocalAgents()[0] || null;
16161
+ const local = ownLocalAgent(flags);
16076
16162
  const context = agentSessionFor(flags, local);
16077
16163
  const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
16078
16164
  if (!Number.isFinite(accountId) || accountId <= 0) {
@@ -16125,7 +16211,7 @@ async function ticketQueueCommand(flags) {
16125
16211
  * listing cannot cause a wrong reclaim even if this list is minutes old.</p>
16126
16212
  */
16127
16213
  async function ticketReclaimSweepCommand(flags) {
16128
- const local = listLocalAgents()[0] || null;
16214
+ const local = ownLocalAgent(flags);
16129
16215
  const context = agentSessionFor(flags, local);
16130
16216
  const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
16131
16217
  if (!Number.isFinite(accountId) || accountId <= 0) {
@@ -16198,7 +16284,7 @@ async function ticketReclaimSweepCommand(flags) {
16198
16284
  * ticket it was given until the record no longer described one piece of work, or to drop the finding.</p>
16199
16285
  */
16200
16286
  async function ticketCreateCommand(flags) {
16201
- const local = listLocalAgents()[0] || null;
16287
+ const local = ownLocalAgent(flags);
16202
16288
  const context = agentSessionFor(flags, local);
16203
16289
  const accountId = Number(flags['account-id'] || flags.accountId || context.accountId);
16204
16290
  if (!Number.isFinite(accountId) || accountId <= 0) {
@@ -16231,7 +16317,7 @@ async function ticketCreateCommand(flags) {
16231
16317
  } catch (err) {
16232
16318
  const body = err && err.response && err.response.data;
16233
16319
  if (body && body.message) {
16234
- console.error('Refused: ' + body.message);
16320
+ console.error(ticketRefusalLine(context, body.message));
16235
16321
  process.exitCode = 1;
16236
16322
  return;
16237
16323
  }
@@ -16986,7 +17072,7 @@ async function agentWorkMapCommand(flags) {
16986
17072
 
16987
17073
  async function agentListCommand(flags) {
16988
17074
  const cwd = process.cwd();
16989
- const mine = listLocalAgents()[0] || null;
17075
+ const mine = ownLocalAgent(flags);
16990
17076
  const context = agentSessionFor(flags, mine);
16991
17077
 
16992
17078
  // An explicit --account-id asks about one account. Otherwise, if this machine has a registered
@@ -17890,7 +17976,7 @@ function printComponentsHelp(subcommand) {
17890
17976
  return;
17891
17977
  }
17892
17978
  if (subcommand === 'sync') {
17893
- console.log('Usage: remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--create-variant-branch] [--summary] [--timeout-ms 60000] [--json]');
17979
+ console.log('Usage: remits-cli components sync [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--create-variant-branch] [--summary] [--timeout-ms 60000] [--resume OPERATION_ID] [--wait-timeout-ms 600000] [--json]');
17894
17980
  console.log(' remits-cli components sync doctor [--json]');
17895
17981
  console.log('');
17896
17982
  console.log('On trunk this reconciles the pushed repository into live component rows.');
@@ -18000,7 +18086,7 @@ function printComponentsHelp(subcommand) {
18000
18086
  console.log(' remits-cli components lanes [--base-url URL] [--account-id ID] [--wait-change [--lane-id ID] [--timeout 600]] [--json]');
18001
18087
  console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
18002
18088
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
18003
- console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--timeout-ms 60000]');
18089
+ console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--timeout-ms 60000] [--resume OPERATION_ID] [--wait-timeout-ms 600000]');
18004
18090
  console.log(' remits-cli components commit [--yes] [--safe] [--message|-m "msg"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
18005
18091
  console.log('');
18006
18092
  console.log(' --force-tombstones applies only on non-trunk variant syncs, when a missing trunk');
@@ -18309,7 +18395,7 @@ async function main() {
18309
18395
  console.log(' remits-cli components lanes [--base-url URL] [--account-id ID] [--json]');
18310
18396
  console.log(' remits-cli components entries --lane-id ID [--base-url URL] [--account-id ID] [--json|--verbose]');
18311
18397
  console.log(' remits-cli components clear [--base-url URL] [--account-id ID] [--branch BRANCH] [--workspace NAME] [--component-type TYPE] [--component-id ID] [--all] [--json|--verbose]');
18312
- console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--timeout-ms 60000]');
18398
+ console.log(' remits-cli components sync [--safe] [--base-url URL] [--account-id ID] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--dry-run] [--summary] [--timeout-ms 60000] [--resume OPERATION_ID] [--wait-timeout-ms 600000]');
18313
18399
  console.log(' remits-cli components commit [--yes] [--safe] [--message|-m \"msg\"] [--allow-empty true|false] [--skip-git true|false] [--branch BRANCH] [--data-mode test|prod] [--force-tombstones] [--json [--summary]]');
18314
18400
  console.log(' remits-cli components promotion [<branch>] [--json] [--no-fail] # promotion readiness + ordered next steps');
18315
18401
  console.log(' remits-cli components branches [--json] # committed branch variants for this account');
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ // Observation never submits another sync. A timeout means the first request's outcome is unknown.
4
+ async function observeSync(observe, operationId, options = {}) {
5
+ const now = options.now || Date.now;
6
+ const sleep = options.sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
7
+ const end = now() + (options.waitMs || 600000);
8
+ // A request can still be queued for admission (workload slot, landing lease) after the client gave up on it,
9
+ // so its receipt may not exist YET. Keep looking for a short grace before calling the outcome unknown.
10
+ const notFoundGraceMs = options.notFoundGraceMs === undefined ? 30000 : options.notFoundGraceMs;
11
+ let firstNotFoundAt = null;
12
+ while (now() < end) {
13
+ let result;
14
+ try { result = await observe(operationId); }
15
+ catch (err) {
16
+ if (err.response && [400, 401, 403, 404].includes(err.response.status)) throw err;
17
+ await sleep(2000);
18
+ continue;
19
+ }
20
+ if (result.operationState === 'SUCCEEDED' || result.operationState === 'FAILED') return result;
21
+ if (result.operationState === 'NOT_FOUND') {
22
+ if (firstNotFoundAt === null) firstNotFoundAt = now();
23
+ if (now() - firstNotFoundAt >= notFoundGraceMs) {
24
+ throw new Error('Sync outcome is unknown for ' + operationId + '. Inspect components sync doctor before submitting a new operation.');
25
+ }
26
+ await sleep(2000);
27
+ continue;
28
+ }
29
+ if (result.operationState !== 'RUNNING') {
30
+ throw new Error('Sync outcome is unknown for ' + operationId + '. Inspect components sync doctor before submitting a new operation.');
31
+ }
32
+ // The server says the request has outlived any process that could still be running it. Waiting longer
33
+ // cannot produce an answer; hand the caller the server's note instead of polling to the timeout.
34
+ if (result.overdue) {
35
+ throw new Error((result.note || 'Sync ' + operationId + ' is overdue and recorded no outcome.') + ' (operation ' + operationId + ')');
36
+ }
37
+ await sleep(2000);
38
+ }
39
+ throw new Error('Sync is still unresolved. Resume observation with: remits-cli components sync --resume ' + operationId);
40
+ }
41
+
42
+ async function submitSync(submit, observe, payload, options = {}) {
43
+ let result;
44
+ try { result = await submit(payload); }
45
+ catch (err) {
46
+ // stageOrRefuse preserves HTTP status on .status when it turns a server message into an Error.
47
+ const status = err.status || (err.response && err.response.status);
48
+ if (err.refused || (status && ![502, 503, 504].includes(status))) throw err;
49
+ return observeSync(observe, payload.operationId, options);
50
+ }
51
+ return result.pending ? observeSync(observe, payload.operationId, options) : result;
52
+ }
53
+
54
+ module.exports = { observeSync, submitSync };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remits/remits-cli",
3
- "version": "0.1.153",
3
+ "version": "0.1.155",
4
4
  "description": "Local CLI for auth, component sync, and live test execution against Remits",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -11,6 +11,7 @@
11
11
  "type": "commonjs",
12
12
  "files": [
13
13
  "index.js",
14
+ "lib/sync-operation.js",
14
15
  "README.md",
15
16
  "scripts/prepare-index-toc.js",
16
17
  "skills/remits-cli/SKILL.md",
@@ -223,7 +223,9 @@ reference named after it.
223
223
  routable and starts nothing. Use `remits-cli agent prune-worktrees` to clean up finished ticket worktrees.
224
224
  (`support-tickets.md`)
225
225
  - **`remits-cli ticket` is the platform's own ticket surface** and works on every Remits platform.
226
- `mcp_support_ticket` is one System Account's convention and refuses tickets it does not own.
226
+ `mcp_support_ticket` is one System Account's convention and refuses tickets it does not own. A ticket
227
+ lives on ONE platform: every ticket refusal names the host that refused it, and `Refused by
228
+ http://localhost:8080` means "wrong host", not "no access" — re-run with the right `--base-url`.
227
229
  (`support-tickets.md`)
228
230
  - **Reading and investigating are parallel-safe; editing one account's repository is exclusive**, via a
229
231
  lease. A refusal is not an error — carry on read-only and hand off with `ticket progress --next-step`.
@@ -583,6 +583,12 @@ For tests specifically:
583
583
  trunk sync reports an untouched component under `unchanged`, not `updated`.
584
584
  - `--timeout-ms` on `components sync` controls the per-request HTTP timeout for every sync request,
585
585
  including `--safe` status/preflight and the durable sync write.
586
+ - New platforms record each mutating `components sync` under a durable operation id, printed before
587
+ submission and saved under the local actor's `sync-operations/`. A lost response triggers status
588
+ observation, never another submission. `components sync --resume <operation-id>` resumes observation
589
+ in the same account, user, branch and workspace; `--wait-timeout-ms` bounds observation (default 10m).
590
+ A process that died mid-sync can leave an unresolved receipt; after an hour the CLI reports it as overdue
591
+ and stops waiting. Inspect `sync doctor` before deliberately starting a new operation. Older platforms cannot recover a result by operation id.
586
592
  - `components sync doctor` does not mutate. Use it when a sync timed out, status/promotion disagree, or
587
593
  you need to know whether staged entries would shadow committed-variant verification. It prints local
588
594
  `HEAD`, `origin/<branch>`, platform last-sync SHA, overlay freshness, repository mismatch, and a
@@ -421,6 +421,23 @@ remits-cli ticket planning --ticket 22454 --board-stage ready_for_qa --blocked-b
421
421
  remits-cli ticket release --ticket 22454 # hand it back to the queue
422
422
  ```
423
423
 
424
+ **Which platform answers.** A ticket id is global, but the record lives on ONE platform. Ticket verbs go
425
+ to `--base-url` when you pass it; otherwise to the registration of THIS terminal's own agent (`agent
426
+ serve`/`register`, or `REMITS_AGENT_ID` in a worker); otherwise to the production platform. Another
427
+ terminal's agent on the same machine is never used — it may be pinned to localhost. Every refusal names
428
+ who refused it: `Refused by http://localhost:8080 (prod lane): No accessible support ticket 67491` is a
429
+ WRONG-HOST answer (a local bootRun does not hold production tickets), not an access problem. Re-run
430
+ against the host you meant; do not retry the same call or look for another door.
431
+
432
+ **`--account-id` does not pick or authorise the ticket.** The ticket's owning account does — the platform
433
+ resolves it from the ticket itself. Leave `--account-id` off lifecycle verbs; it is for `queue`, `create`
434
+ and `reclaim`, which have no ticket to resolve from.
435
+
436
+ **Write flags out in full.** `B="--data-mode prod --base-url URL"; remits-cli ticket accept ... $B` passes
437
+ ONE argument under zsh (it does not split an unquoted variable), so the command ran with neither flag. The
438
+ CLI now refuses such an argument; use `B=(--data-mode prod --base-url URL)` and `"${B[@]}"` if you need a
439
+ variable at all.
440
+
424
441
  **See the whole queue before deciding your ticket is unique.** Alert-raised tickets arrive in
425
442
  clusters, and the same root cause routinely appears under several different account names — so the
426
443
  first useful question is usually "how many of these are one fix?", and you cannot ask it from a single
@@ -17,6 +17,8 @@
17
17
  | Symptom | Fix |
18
18
  |---|---|
19
19
  | 401 or `Not authenticated` | Run `remits-cli auth` |
20
+ | `Refused by <host>: No accessible support ticket N` | Read the host. A local or staging host does not hold production tickets, so this is a wrong-host answer: re-run with `--base-url` set to the platform that owns the ticket. `--account-id` cannot fix it (the ticket's own account is what is checked). See `support-tickets.md` → *Moving a ticket through its lifecycle*. |
21
+ | `One argument holds several flags` | Your shell passed `--flag value --flag value` as ONE word (zsh does not split an unquoted `$VAR`). Write the flags out, or use an array and `"${B[@]}"`. |
20
22
  | `account-info.json not found` | Run from the account repo root |
21
23
  | Test not found (404) | New component not staged yet. Run `remits-cli components stage` first. For new tests (no ID), run by name not ID. |
22
24
  | Stage shows 0 updated | No changes since last stage (hash dedup) |