@skrr-ai/cli 0.1.24 → 0.1.25

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.
@@ -199,10 +199,10 @@ export declare abstract class BaseCommand extends Command {
199
199
  *
200
200
  * Shared by every command that accepts an assignee reference. A failure is
201
201
  * fatal rather than a silent fall-back to a global directory search: the
202
- * server only accepts assignees who are members of the space
203
- * (`ASSIGNEE_NOT_IN_SPACE`), so widening the lookup could only ever produce
204
- * a request guaranteed to be rejected or, worse, resolve a typo to a
205
- * stranger the caller never intended to name.
202
+ * server accepts space members, plus only the caller's own unshared agents
203
+ * (`AgentTaskConsent`; anything else is `ASSIGNEE_NOT_IN_SPACE`), so widening
204
+ * the lookup could only ever produce a request the server refuses or,
205
+ * worse, resolve a typo to a stranger the caller never intended to name.
206
206
  */
207
207
  protected loadSpaceMembers(spaceId: string): Promise<SpaceMember[]>;
208
208
  /**
@@ -645,10 +645,10 @@ class BaseCommand extends core_1.Command {
645
645
  *
646
646
  * Shared by every command that accepts an assignee reference. A failure is
647
647
  * fatal rather than a silent fall-back to a global directory search: the
648
- * server only accepts assignees who are members of the space
649
- * (`ASSIGNEE_NOT_IN_SPACE`), so widening the lookup could only ever produce
650
- * a request guaranteed to be rejected or, worse, resolve a typo to a
651
- * stranger the caller never intended to name.
648
+ * server accepts space members, plus only the caller's own unshared agents
649
+ * (`AgentTaskConsent`; anything else is `ASSIGNEE_NOT_IN_SPACE`), so widening
650
+ * the lookup could only ever produce a request the server refuses or,
651
+ * worse, resolve a typo to a stranger the caller never intended to name.
652
652
  */
653
653
  async loadSpaceMembers(spaceId) {
654
654
  let response;
@@ -1376,8 +1376,10 @@ function describeAssigneeNotInSpace(serverMessage, bin = 'skrr') {
1376
1376
  const base = serverMessage?.trim() || 'That assignee is not a member of this space.';
1377
1377
  if (base.includes(`${bin} spaces members add`))
1378
1378
  return base;
1379
+ // Two ways forward, because the server accepts two consents: membership, or
1380
+ // an owner assigning their own unshared agent to one task (AgentTaskConsent).
1379
1381
  return (`${base}\n → add it to the space with \`${bin} spaces members add\`, ` +
1380
- 'or assign someone already in it.');
1382
+ 'assign someone already in it, or — for your own unshared agent — assign it yourself.');
1381
1383
  }
1382
1384
  /**
1383
1385
  * Server-declared TERMINAL refusals: a state conflict the caller must resolve
@@ -1,3 +1,4 @@
1
+ import { type SpaceAutoExecutorRefillState } from '@skrr-ai/data-provider';
1
2
  import { BaseCommand } from '../../../base-command';
2
3
  export default class SpacesAutoExecutorPreview extends BaseCommand {
3
4
  static description: string;
@@ -10,3 +11,10 @@ export default class SpacesAutoExecutorPreview extends BaseCommand {
10
11
  };
11
12
  run(): Promise<void>;
12
13
  }
14
+ /**
15
+ * The refill latch in words: whether start work is owed, why it last woke, and
16
+ * what each recent task-pool change did. This is how "the board is not picking
17
+ * up work" becomes a question with an answer. `undefined` is a server that does
18
+ * not report it; `null` is one that could not read it, and says so.
19
+ */
20
+ export declare function refillLines(refill: SpaceAutoExecutorRefillState | null | undefined): string[];
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.refillLines = refillLines;
3
4
  const core_1 = require("@oclif/core");
4
5
  const data_provider_1 = require("@skrr-ai/data-provider");
5
6
  const base_command_1 = require("../../../base-command");
@@ -49,6 +50,8 @@ class SpacesAutoExecutorPreview extends base_command_1.BaseCommand {
49
50
  if (capacity.budget && !capacity.budget.unlimited) {
50
51
  this.log(`Budget: ${capacity.budget.consumedStarts}/${capacity.budget.maxStarts} starts used this round`);
51
52
  }
53
+ for (const line of refillLines(result.refill))
54
+ this.log(line);
52
55
  const { actions, summary } = plan;
53
56
  this.log('');
54
57
  this.log(`Board: ${summary.readyCount} ready · ${summary.unassignedCount} unassigned · ` +
@@ -70,6 +73,44 @@ class SpacesAutoExecutorPreview extends base_command_1.BaseCommand {
70
73
  }
71
74
  }
72
75
  exports.default = SpacesAutoExecutorPreview;
76
+ /**
77
+ * The refill latch in words: whether start work is owed, why it last woke, and
78
+ * what each recent task-pool change did. This is how "the board is not picking
79
+ * up work" becomes a question with an answer. `undefined` is a server that does
80
+ * not report it; `null` is one that could not read it, and says so.
81
+ */
82
+ function refillLines(refill) {
83
+ if (refill === undefined)
84
+ return [];
85
+ if (refill === null)
86
+ return ['Refill: state unavailable (the server could not read it)'];
87
+ const owed = refill.requestedGeneration - refill.processedGeneration;
88
+ const lines = [
89
+ `Refill: ${refill.pending
90
+ ? `${owed} wake${owed === 1 ? '' : 's'} owed (generation ${refill.processedGeneration} → ${refill.requestedGeneration})`
91
+ : `caught up (generation ${refill.processedGeneration})`}${refill.lastReason ? ` · last: ${refill.lastReason}` : ''}`,
92
+ ];
93
+ if (refill.notBefore)
94
+ lines.push(` deferred until ${refill.notBefore}`);
95
+ if (refill.waitingOnAgentIds.length) {
96
+ lines.push(` waiting on agent capacity: ${refill.waitingOnAgentIds.join(', ')}`);
97
+ }
98
+ const wakes = refill.taskPool.recentWakes;
99
+ if (wakes.length) {
100
+ lines.push('');
101
+ lines.push(`Recent task-pool changes (${wakes.length}):`);
102
+ for (const wake of wakes) {
103
+ const effect = wake.refillRelevant === true
104
+ ? 'can start work'
105
+ : wake.refillRelevant === false
106
+ ? 'nothing to start'
107
+ : 'unclassified';
108
+ const outcome = wake.status === 'processed' ? (wake.decision ?? 'processed') : wake.status;
109
+ lines.push(` - #${wake.generation} ${wake.mutation} · ${effect} · ${outcome}`);
110
+ }
111
+ }
112
+ return lines;
113
+ }
73
114
  function taskLine(task) {
74
115
  return `${task.identifier ? `${task.identifier} ` : ''}${task.title}`;
75
116
  }
@@ -0,0 +1,14 @@
1
+ import { BaseCommand } from '../../base-command';
2
+ /**
3
+ * `skrr spaces task-rollups` — task-status counts for every Space you can view,
4
+ * in one request: the same rollup the Spaces sidebar row reads, counted by the
5
+ * project-rollup rules (`done` is done only; `open` excludes cancelled).
6
+ */
7
+ export default class SpacesTaskRollups extends BaseCommand {
8
+ static description: string;
9
+ static examples: string[];
10
+ static flags: {
11
+ json: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
12
+ };
13
+ run(): Promise<void>;
14
+ }
@@ -0,0 +1,66 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const core_1 = require("@oclif/core");
4
+ const data_provider_1 = require("@skrr-ai/data-provider");
5
+ const base_command_1 = require("../../base-command");
6
+ /**
7
+ * `skrr spaces task-rollups` — task-status counts for every Space you can view,
8
+ * in one request: the same rollup the Spaces sidebar row reads, counted by the
9
+ * project-rollup rules (`done` is done only; `open` excludes cancelled).
10
+ */
11
+ class SpacesTaskRollups extends base_command_1.BaseCommand {
12
+ static description = 'Show task-status counts for every space you can view';
13
+ static examples = [
14
+ '<%= config.bin %> spaces task-rollups',
15
+ '<%= config.bin %> spaces task-rollups --json',
16
+ ];
17
+ static flags = {
18
+ json: core_1.Flags.boolean({ description: 'Output as JSON' }),
19
+ };
20
+ async run() {
21
+ this.requireAuth();
22
+ const { flags } = await this.parse(SpacesTaskRollups);
23
+ let response;
24
+ // Titles are best-effort: the rollup carries only ids, and a failed title
25
+ // lookup must not hide counts the rollup did return.
26
+ let titles = new Map();
27
+ try {
28
+ const [rollups, spaces] = await Promise.all([
29
+ data_provider_1.dataService.listSpaceTaskRollups(),
30
+ data_provider_1.dataService.listSpaces({ includeArchived: true, limit: 500 }).catch(() => null),
31
+ ]);
32
+ response = rollups;
33
+ const rows = (spaces?.data ||
34
+ []);
35
+ titles = new Map(rows.map((space) => [String(space.id), space.title || '']));
36
+ }
37
+ catch (err) {
38
+ this.handleApiError(err);
39
+ return;
40
+ }
41
+ if (flags.json) {
42
+ this.log(JSON.stringify(response, null, 2));
43
+ return;
44
+ }
45
+ if (response.degraded) {
46
+ // A partial walk is not the whole picture; say so before the numbers.
47
+ this.log(`Degraded: ${(response.warnings || []).join('; ') || 'some spaces were not counted'}`);
48
+ }
49
+ const rows = [...(response.rollups || [])].sort((a, b) => b.tasks.open - a.tasks.open);
50
+ if (rows.length === 0) {
51
+ this.log('No spaces with tasks.');
52
+ return;
53
+ }
54
+ const label = (spaceId) => {
55
+ const title = titles.get(spaceId);
56
+ const text = title ? `${title} (${spaceId.slice(0, 8)})` : spaceId;
57
+ return text.length > 48 ? `${text.slice(0, 45)}...` : text;
58
+ };
59
+ this.log(`${'SPACE'.padEnd(48)} ${'OPEN'.padStart(5)} ${'DONE'.padStart(5)} ${'BLOCKED'.padStart(7)} ${'TOTAL'.padStart(5)}`);
60
+ for (const { spaceId, tasks } of rows) {
61
+ this.log(`${label(spaceId).padEnd(48)} ${String(tasks.open).padStart(5)} ${String(tasks.done).padStart(5)} ` +
62
+ `${String(tasks.blocked).padStart(7)} ${String(tasks.total).padStart(5)}`);
63
+ }
64
+ }
65
+ }
66
+ exports.default = SpacesTaskRollups;
@@ -52,6 +52,28 @@ class SpacesWorkSyncTelemetry extends base_command_1.BaseCommand {
52
52
  this.log(`Verdict: ${summary.rollout.verdict}`);
53
53
  this.log(`Sampled rows: ${summary.sampledEvents}`);
54
54
  this.log(`Repair backlog: ${summary.repairBacklog}`);
55
+ if (summary.coverage && summary.coverage.hits + summary.coverage.misses > 0) {
56
+ const { hits, misses, hitRate } = summary.coverage;
57
+ this.log(`Coverage: ${hits} hits · ${misses} misses` +
58
+ (hitRate === null ? '' : ` (${Math.round(hitRate * 1000) / 10}% of cold opens)`));
59
+ }
60
+ // Before the zero-events early return: writes are measured from the command
61
+ // queue, not from board telemetry, so a Space can have one without the other.
62
+ const commandRows = Object.entries(summary.commands || {}).sort(([a], [b]) => a.localeCompare(b));
63
+ if (commandRows.length > 0) {
64
+ const percent = (value) => value === null ? 'n/a' : `${Math.round(value * 1000) / 10}%`;
65
+ this.log('');
66
+ this.log('Writes (durable command queue):');
67
+ for (const [kind, stats] of commandRows) {
68
+ this.log(` ${kind.padEnd(20)} ${stats.commands} commands · ${stats.accepted} accepted · ` +
69
+ `${stats.pending} pending · ${stats.deadLettered} refused`);
70
+ this.log(` ${''.padEnd(20)} version conflicts ${stats.versionConflicts} ` +
71
+ `(${percent(stats.rates.versionConflict)} of settled) · authorization changed ` +
72
+ `${stats.authorizationChanged} (${percent(stats.rates.authorizationChanged)}) · ` +
73
+ `duplicate deliveries ${stats.duplicateDeliveries} ` +
74
+ `(${percent(stats.rates.duplicateDelivery)} of deliveries)`);
75
+ }
76
+ }
55
77
  // Zero sampled rows is a REAL answer and the most misread one: it means
56
78
  // nothing has been measured in this Space, not that everything is fine. The
57
79
  // server already distinguishes it (`insufficient_evidence`); say so here too
@@ -74,7 +74,7 @@ class TasksAssign extends base_command_1.BaseCommand {
74
74
  options: ['agent', 'human'],
75
75
  }),
76
76
  list: core_1.Flags.boolean({
77
- description: "List the task's space members (who can be assigned) and exit",
77
+ description: "List the task's space members and exit (your own unshared agents can also be assigned, by id)",
78
78
  }),
79
79
  json: core_1.Flags.boolean({ description: 'Output as JSON' }),
80
80
  };
@@ -460,8 +460,9 @@ class TasksCreate extends base_command_1.BaseCommand {
460
460
  defaultAgentResolution = {
461
461
  applied: false,
462
462
  source: 'none',
463
- note: 'Created unassigned: the agent bound to this session is not a member of that space. ' +
464
- 'Add it to the space, or pass an assignee explicitly.',
463
+ note: 'Created unassigned: the agent bound to this session is not a member of that space, ' +
464
+ 'and it is not your own agent shared with no one else, which is the only other way ' +
465
+ 'an agent can work there. Add it to the space, or pass an assignee explicitly.',
465
466
  };
466
467
  try {
467
468
  const result = await this.createTaskOrRecover(payload);
@@ -572,9 +573,14 @@ class TasksCreate extends base_command_1.BaseCommand {
572
573
  const dropped = (0, tasks_1.droppedDefaultAssignees)(resolution, memberIds);
573
574
  const warnings = [];
574
575
  if (dropped.agentIds.length > 0) {
576
+ // Membership is one of TWO consents the server accepts (AgentTaskConsent);
577
+ // the other — your own agent, shared with no one else — needs the agent's
578
+ // sharing, which a dry run does not read. So this says what decides it
579
+ // rather than predicting a drop that owner delegation would not make.
575
580
  warnings.push(`The defaulted assignee agent ${dropped.agentIds.join(', ')} is not a member of ` +
576
- `space ${spaceId}, so the real create will drop it and record the task ` +
577
- 'unassigned. Add the agent to the space, or pass an assignee explicitly.');
581
+ `space ${spaceId}. The real create keeps it only if it is your own agent shared ` +
582
+ 'with no one else; otherwise it records the task unassigned. Add the agent to the ' +
583
+ 'space, or pass an assignee explicitly.');
578
584
  }
579
585
  if (dropped.userIds.length > 0) {
580
586
  warnings.push(`The defaulted assignee ${dropped.userIds.join(', ')} is not a member of space ` +
@@ -37,6 +37,8 @@ const config_1 = require("./config");
37
37
  // (b) tests that mock './auth-storage' get a single mock target.
38
38
  const auth_storage_1 = require("./auth-storage");
39
39
  const credential_resolver_1 = require("./credential-resolver");
40
+ const data_provider_1 = require("@skrr-ai/data-provider");
41
+ const idempotency_scope_1 = require("./idempotency-scope");
40
42
  const node_adapter_1 = require("./node-adapter");
41
43
  const refresh_1 = require("./refresh");
42
44
  const delegated_cli_1 = require("./delegated-cli");
@@ -95,6 +97,12 @@ async function apiFetch(pathOrUrl, opts = {}) {
95
97
  if (opts.body !== undefined && method !== 'GET') {
96
98
  body = JSON.stringify(opts.body);
97
99
  }
100
+ // Same rule as every `dataService` write (`@skrr-ai/data-provider`
101
+ // idempotency), chosen once so the 401 retries below resend the same key.
102
+ (0, idempotency_scope_1.bindAutonomousIdempotencyScope)();
103
+ const callerHeaders = method === 'GET'
104
+ ? (opts.headers ?? {})
105
+ : (0, data_provider_1.withIdempotencyKey)(opts.headers, { method, url: pathOrUrl, body: opts.body });
98
106
  // A trusted Agent process cannot smuggle an explicit human credential into
99
107
  // this escape hatch. The daemon-issued session token is the sole principal.
100
108
  //
@@ -146,7 +154,7 @@ async function apiFetch(pathOrUrl, opts = {}) {
146
154
  // Caller headers are applied BEFORE the auth header below is read, and
147
155
  // deliberately cannot clobber Authorization / Content-Type: an idempotency
148
156
  // key is additive metadata, not a way to re-auth the request.
149
- for (const [k, v] of Object.entries(opts.headers ?? {})) {
157
+ for (const [k, v] of Object.entries(callerHeaders)) {
150
158
  const lower = k.toLowerCase();
151
159
  if (lower === 'authorization' || lower === 'content-type')
152
160
  continue;
@@ -9,12 +9,13 @@
9
9
  * reference into exactly one member of the task's space, or an actionable
10
10
  * failure that names the candidates.
11
11
  *
12
- * Scoping to the space is not just ergonomics. The server rejects assignees
13
- * that are not space members (`SpacePermissionService.validateAssigneeSpaceMembership`
14
- * → `ASSIGNEE_NOT_IN_SPACE`), so resolving against the space membership list
15
- * means a name that resolves is a name that will be accepted we never build
16
- * a request the server is guaranteed to refuse, and we cannot accidentally
17
- * leak a directory-wide user search into a space the caller can't see.
12
+ * Scoping to the space is not just ergonomics. The server accepts space members
13
+ * and, beyond them, only the caller's own agent when it is shared with no one
14
+ * else (`AgentTaskConsent` otherwise `ASSIGNEE_NOT_IN_SPACE`), so a name that
15
+ * resolves against the membership list is a name that will be accepted, and we
16
+ * cannot accidentally leak a directory-wide user search into a space the caller
17
+ * can't see. An owner-delegated agent is not a member and so is not resolvable
18
+ * by name here; it is passed by raw id.
18
19
  */
19
20
  export type MemberKind = 'user' | 'agent';
20
21
  export type SpaceMember = {
@@ -139,6 +139,8 @@ export interface DaemonBrokerOptions {
139
139
  profile?: string;
140
140
  /** Override the bootstrap file path entirely (tests / unusual layouts). */
141
141
  bootstrapPathOverride?: string;
142
+ /** Test seam: the ordered candidate list, when one path is not enough. */
143
+ candidatePathsOverride?: readonly string[];
142
144
  /** Override total request timeout. Tests use a short value. */
143
145
  timeoutMs?: number;
144
146
  }
@@ -159,6 +161,24 @@ export declare function resolveProfileBootstrapPaths(primary: string, entryNames
159
161
  * deterministic when more than one matching daemon is running.
160
162
  */
161
163
  export declare function resolveBootstrapCandidatePaths(profile?: string): string[];
164
+ /**
165
+ * A Dedicated Runtime guest's workload hand-off descriptor.
166
+ *
167
+ * On that product the daemon runs as `skrr-runtime` and everything the user runs
168
+ * — terminals, Bash tool calls, engines — as `skrr-workload`, so the island
169
+ * bootstrap above sits in a home this process cannot read. The daemon publishes
170
+ * this second descriptor to the shared workload group instead
171
+ * (`daemon/src/dedicated-cli-handoff.ts`). Its secret is accepted by the
172
+ * cli-handoff route and nothing else, so it is a hand-off candidate ONLY: it is
173
+ * deliberately absent from `resolveBootstrapCandidatePaths`, which message-intent
174
+ * submission also reads and which needs the island secret.
175
+ *
176
+ * Fixed and root-controlled (the daemon unit's RuntimeDirectory), so nothing the
177
+ * user configures can point the broker somewhere else.
178
+ */
179
+ export declare const DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = "/run/skrr-dedicated-runtime/cli-handoff/descriptor.json";
180
+ /** Where the cli-handoff broker looks, in order. */
181
+ export declare function resolveCliHandoffCandidatePaths(profile?: string): string[];
162
182
  /**
163
183
  * Resolve the first live daemon bootstrap for a profile. Message-intent
164
184
  * submission reuses the same authenticated loopback discovery as the login
@@ -71,10 +71,12 @@ var __importStar = (this && this.__importStar) || (function () {
71
71
  };
72
72
  })();
73
73
  Object.defineProperty(exports, "__esModule", { value: true });
74
+ exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = void 0;
74
75
  exports.normalizeBaseUrl = normalizeBaseUrl;
75
76
  exports.resolveBootstrapPath = resolveBootstrapPath;
76
77
  exports.resolveProfileBootstrapPaths = resolveProfileBootstrapPaths;
77
78
  exports.resolveBootstrapCandidatePaths = resolveBootstrapCandidatePaths;
79
+ exports.resolveCliHandoffCandidatePaths = resolveCliHandoffCandidatePaths;
78
80
  exports.findLiveDaemonBootstrap = findLiveDaemonBootstrap;
79
81
  exports.readBootstrap = readBootstrap;
80
82
  exports.attemptDaemonBrokerLogin = attemptDaemonBrokerLogin;
@@ -164,6 +166,33 @@ function resolveBootstrapCandidatePaths(profile = 'default') {
164
166
  return process.platform === 'darwin' ? [primary] : [primary, legacyBootstrapPath()];
165
167
  }
166
168
  }
169
+ /**
170
+ * A Dedicated Runtime guest's workload hand-off descriptor.
171
+ *
172
+ * On that product the daemon runs as `skrr-runtime` and everything the user runs
173
+ * — terminals, Bash tool calls, engines — as `skrr-workload`, so the island
174
+ * bootstrap above sits in a home this process cannot read. The daemon publishes
175
+ * this second descriptor to the shared workload group instead
176
+ * (`daemon/src/dedicated-cli-handoff.ts`). Its secret is accepted by the
177
+ * cli-handoff route and nothing else, so it is a hand-off candidate ONLY: it is
178
+ * deliberately absent from `resolveBootstrapCandidatePaths`, which message-intent
179
+ * submission also reads and which needs the island secret.
180
+ *
181
+ * Fixed and root-controlled (the daemon unit's RuntimeDirectory), so nothing the
182
+ * user configures can point the broker somewhere else.
183
+ */
184
+ exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = '/run/skrr-dedicated-runtime/cli-handoff/descriptor.json';
185
+ /** Where the cli-handoff broker looks, in order. */
186
+ function resolveCliHandoffCandidatePaths(profile = 'default') {
187
+ const candidates = resolveBootstrapCandidatePaths(profile);
188
+ // Any profile: a guest runs one daemon, and the auto-broker resolves the
189
+ // default profile regardless of `--profile`, so a narrower condition here would
190
+ // only describe behaviour production never reaches.
191
+ if (process.platform === 'linux') {
192
+ candidates.push(exports.DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR);
193
+ }
194
+ return candidates;
195
+ }
167
196
  /**
168
197
  * Pre-Phase-K bootstrap path (non-darwin). Read-only fallback consulted
169
198
  * when the canonical path is absent — covers a freshly-upgraded daemon
@@ -247,13 +276,15 @@ function readBootstrap(filePath) {
247
276
  */
248
277
  async function attemptDaemonBrokerLogin(opts) {
249
278
  const profile = opts.profile ?? 'default';
250
- const candidates = opts.bootstrapPathOverride
251
- ? [opts.bootstrapPathOverride]
252
- : resolveBootstrapCandidatePaths(profile);
253
- let bootstrap = null;
254
- let usedPath = null;
255
- let unboundBootstrap = null;
256
- let unboundPath = null;
279
+ const candidates = opts.candidatePathsOverride
280
+ ? [...opts.candidatePathsOverride]
281
+ : opts.bootstrapPathOverride
282
+ ? [opts.bootstrapPathOverride]
283
+ : resolveCliHandoffCandidatePaths(profile);
284
+ // Bound candidates (their serverUrl matches this CLI) in order, then unbound
285
+ // ones. Every usable candidate is kept, not just the first: see below.
286
+ const usable = [];
287
+ const unbound = [];
257
288
  let mismatchedBootstrap = null;
258
289
  for (const candidate of candidates) {
259
290
  const candidateBootstrap = readBootstrap(candidate);
@@ -269,19 +300,13 @@ async function attemptDaemonBrokerLogin(opts) {
269
300
  }
270
301
  if (typeof candidateBootstrap.serverUrl !== 'string' ||
271
302
  candidateBootstrap.serverUrl.length === 0) {
272
- unboundBootstrap ??= candidateBootstrap;
273
- unboundPath ??= candidate;
303
+ unbound.push(candidateBootstrap);
274
304
  continue;
275
305
  }
276
- bootstrap = candidateBootstrap;
277
- usedPath = candidate;
278
- break;
279
- }
280
- if (!bootstrap && unboundBootstrap && unboundPath) {
281
- bootstrap = unboundBootstrap;
282
- usedPath = unboundPath;
306
+ usable.push(candidateBootstrap);
283
307
  }
284
- if (!bootstrap || !usedPath) {
308
+ usable.push(...unbound);
309
+ if (usable.length === 0) {
285
310
  if (mismatchedBootstrap?.serverUrl) {
286
311
  return {
287
312
  ok: false,
@@ -296,6 +321,22 @@ async function attemptDaemonBrokerLogin(opts) {
296
321
  detail: `No daemon bootstrap file found (looked in ${candidates.join(', ')})`,
297
322
  };
298
323
  }
324
+ // A candidate nothing is listening behind moves on to the next one. Liveness is
325
+ // only a pid check, and EPERM counts as alive — on a Dedicated Runtime guest
326
+ // nearly every pid belongs to another uid, so a stale island file in the
327
+ // workload's own home (restored dotfiles, say) passes it and used to shadow the
328
+ // daemon's live hand-off descriptor forever, with `skrr` reporting "Not signed
329
+ // in". Only a refused connection falls through: any answer, or a timeout, is a
330
+ // daemon that is really there, and its outcome stands.
331
+ let outcome = null;
332
+ for (const bootstrap of usable) {
333
+ outcome = await brokerThrough(bootstrap, opts);
334
+ if (outcome.ok || outcome.reason !== 'network')
335
+ return outcome;
336
+ }
337
+ return outcome;
338
+ }
339
+ async function brokerThrough(bootstrap, opts) {
299
340
  const url = `http://${bootstrap.host}:${bootstrap.port}/v1/auth/cli-handoff`;
300
341
  const timeoutMs = opts.timeoutMs ?? REQUEST_TIMEOUT_MS;
301
342
  const controller = new AbortController();
@@ -0,0 +1,10 @@
1
+ /**
2
+ * A process running one autonomous ACTION binds every write to it, so the same
3
+ * action re-run in a new process converges on the server instead of writing
4
+ * twice. The rule itself lives in `@skrr-ai/data-provider` (`idempotency.ts`),
5
+ * shared with the web client; the CLI only says which action it is.
6
+ *
7
+ * Bound by both transports — the `dataService` adapter and `apiFetch` — because
8
+ * either may be the first to write.
9
+ */
10
+ export declare function bindAutonomousIdempotencyScope(env?: NodeJS.ProcessEnv): void;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.bindAutonomousIdempotencyScope = bindAutonomousIdempotencyScope;
4
+ const data_provider_1 = require("@skrr-ai/data-provider");
5
+ /**
6
+ * A process running one autonomous ACTION binds every write to it, so the same
7
+ * action re-run in a new process converges on the server instead of writing
8
+ * twice. The rule itself lives in `@skrr-ai/data-provider` (`idempotency.ts`),
9
+ * shared with the web client; the CLI only says which action it is.
10
+ *
11
+ * Bound by both transports — the `dataService` adapter and `apiFetch` — because
12
+ * either may be the first to write.
13
+ */
14
+ function bindAutonomousIdempotencyScope(env = process.env) {
15
+ (0, data_provider_1.setIdempotencyScope)(env.OVERSKY_AUTONOMOUS_ACTION_KEY?.trim() || null);
16
+ }
@@ -1,4 +1,4 @@
1
- import type { HttpAdapter } from '@skrr-ai/data-provider';
1
+ import { type HttpAdapter } from '@skrr-ai/data-provider';
2
2
  import { type ResolvedCredential } from '@skrr-ai/auth-core';
3
3
  import './auth-core-init';
4
4
  /**
@@ -5,7 +5,8 @@ exports.clearAdapterCredentialOverride = clearAdapterCredentialOverride;
5
5
  exports.getAdapterCredentialOverride = getAdapterCredentialOverride;
6
6
  exports.createNodeAdapter = createNodeAdapter;
7
7
  exports.__recomputeCredentialForTest = __recomputeCredentialForTest;
8
- const node_crypto_1 = require("node:crypto");
8
+ const data_provider_1 = require("@skrr-ai/data-provider");
9
+ const idempotency_scope_1 = require("./idempotency-scope");
9
10
  const auth_core_1 = require("@skrr-ai/auth-core");
10
11
  const config_1 = require("./config");
11
12
  const auth_storage_1 = require("./auth-storage");
@@ -72,6 +73,7 @@ function currentCredential() {
72
73
  }
73
74
  function createNodeAdapter(opts = {}) {
74
75
  const { onTokenRefresh, onAuthFailure } = opts;
76
+ (0, idempotency_scope_1.bindAutonomousIdempotencyScope)();
75
77
  // In-process single-flight: collapses N parallel 401s into one refresh
76
78
  // round-trip. Cross-process serialization happens inside refresh.ts.
77
79
  let refreshInFlight = null;
@@ -157,15 +159,6 @@ function createNodeAdapter(opts = {}) {
157
159
  }
158
160
  }
159
161
  const finalUrl = appendQuery(resolveUrl(url), options?.params);
160
- const actionKey = process.env.OVERSKY_AUTONOMOUS_ACTION_KEY?.trim();
161
- const hasIdempotencyKey = Object.keys(headers).some((key) => key.toLowerCase() === 'x-idempotency-key');
162
- if (actionKey && method !== 'GET' && !isFormData && !hasIdempotencyKey) {
163
- const requestUrl = new URL(finalUrl);
164
- const scope = `${actionKey}\u001f${method}\u001f${requestUrl.pathname}${requestUrl.search}\u001f${typeof finalBody === 'string' ? finalBody : ''}`;
165
- headers['X-Idempotency-Key'] = `autonomous:${(0, node_crypto_1.createHash)('sha256')
166
- .update(scope)
167
- .digest('hex')}`;
168
- }
169
162
  return fetch(finalUrl, { method, headers, body: finalBody });
170
163
  }
171
164
  /**
@@ -291,6 +284,15 @@ function createNodeAdapter(opts = {}) {
291
284
  return true;
292
285
  }
293
286
  async function request(method, url, body, options, isFormData = false) {
287
+ // Writes that arrive through `request.ts` already carry a key. A direct
288
+ // adapter call gets one by the same shared rule, chosen HERE, once, so the
289
+ // 401-refresh retries below resend the same key rather than a new one.
290
+ if (method !== 'GET' && !isFormData && !(0, data_provider_1.hasIdempotencyKey)(options?.headers)) {
291
+ options = {
292
+ ...(options ?? {}),
293
+ headers: (0, data_provider_1.withIdempotencyKey)(options?.headers, { method, url, body }),
294
+ };
295
+ }
294
296
  let res = await rawRequest(method, url, body, options, isFormData);
295
297
  // Never retry auth endpoints themselves — prevents refresh recursion
296
298
  // and lets login/logout/refresh surface their real errors.
@@ -147,6 +147,13 @@ exports.TRIGGER_EVENT_CATALOG = {
147
147
  'task.assigned',
148
148
  'task.completed',
149
149
  'task.archived',
150
+ 'task.unassigned',
151
+ 'task.scheduled',
152
+ 'task.rescheduled',
153
+ 'task.unscheduled',
154
+ 'task.deleted',
155
+ 'task.execution_completed',
156
+ 'task.execution_failed',
150
157
  ],
151
158
  space: [
152
159
  'space.created',
@@ -27,9 +27,44 @@ export type WorkSyncEventStats = {
27
27
  p95Bytes: number | null;
28
28
  mismatchCount: number;
29
29
  };
30
+ /**
31
+ * What happened to WRITES of one operation kind, from the durable command queue.
32
+ * Rates are `null` when their denominator is zero: conflict rates are over
33
+ * settled commands (accepted + dead-lettered), the duplicate rate over deliveries.
34
+ */
35
+ export type WorkSyncCommandStats = {
36
+ commands: number;
37
+ deliveries: number;
38
+ duplicateDeliveries: number;
39
+ accepted: number;
40
+ pending: number;
41
+ deadLettered: number;
42
+ versionConflicts: number;
43
+ authorizationChanged: number;
44
+ expired: number;
45
+ retryExhausted: number;
46
+ otherRejections: number;
47
+ rates: {
48
+ versionConflict: number | null;
49
+ authorizationChanged: number | null;
50
+ duplicateDelivery: number | null;
51
+ };
52
+ };
30
53
  export type WorkSyncTelemetrySummary = {
31
54
  sampledEvents: number;
32
55
  events: Record<string, WorkSyncEventStats>;
56
+ /** Absent from a server older than the write-side rates. */
57
+ commands?: Record<string, WorkSyncCommandStats>;
58
+ /**
59
+ * Cold opens of a task detail plane that found (hit) or did not find (miss) a
60
+ * completed coverage record on the device. `hitRate` is null with no cold
61
+ * opens. Absent from a server older than the coverage read path.
62
+ */
63
+ coverage?: {
64
+ hits: number;
65
+ misses: number;
66
+ hitRate: number | null;
67
+ };
33
68
  repairBacklog: number;
34
69
  rollout: {
35
70
  verdict: 'ready' | 'hold' | 'insufficient_evidence';