@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.
- package/dist/base-command.d.ts +4 -4
- package/dist/base-command.js +7 -5
- package/dist/commands/spaces/auto-executor/preview.d.ts +8 -0
- package/dist/commands/spaces/auto-executor/preview.js +41 -0
- package/dist/commands/spaces/task-rollups.d.ts +14 -0
- package/dist/commands/spaces/task-rollups.js +66 -0
- package/dist/commands/spaces/work-sync/telemetry.js +22 -0
- package/dist/commands/tasks/assign.js +1 -1
- package/dist/commands/tasks/create.js +10 -4
- package/dist/lib/api-fetch.js +9 -1
- package/dist/lib/assignee-resolver.d.ts +7 -6
- package/dist/lib/daemonBroker.d.ts +20 -0
- package/dist/lib/daemonBroker.js +58 -17
- package/dist/lib/idempotency-scope.d.ts +10 -0
- package/dist/lib/idempotency-scope.js +16 -0
- package/dist/lib/node-adapter.d.ts +1 -1
- package/dist/lib/node-adapter.js +12 -10
- package/dist/lib/triggers.js +7 -0
- package/dist/lib/work-sync.d.ts +35 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +71 -21
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +71 -21
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +1515 -1459
- package/oclif.manifest.json +22476 -22416
- package/package.json +1 -1
package/dist/base-command.d.ts
CHANGED
|
@@ -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
|
|
203
|
-
* (`ASSIGNEE_NOT_IN_SPACE`), so widening
|
|
204
|
-
*
|
|
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
|
/**
|
package/dist/base-command.js
CHANGED
|
@@ -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
|
|
649
|
-
* (`ASSIGNEE_NOT_IN_SPACE`), so widening
|
|
650
|
-
*
|
|
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
|
-
'
|
|
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 (
|
|
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
|
-
'
|
|
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}
|
|
577
|
-
'unassigned. Add the agent to the
|
|
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 ` +
|
package/dist/lib/api-fetch.js
CHANGED
|
@@ -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(
|
|
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
|
|
13
|
-
*
|
|
14
|
-
* → `ASSIGNEE_NOT_IN_SPACE`), so
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
package/dist/lib/daemonBroker.js
CHANGED
|
@@ -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.
|
|
251
|
-
? [opts.
|
|
252
|
-
:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
273
|
-
unboundPath ??= candidate;
|
|
303
|
+
unbound.push(candidateBootstrap);
|
|
274
304
|
continue;
|
|
275
305
|
}
|
|
276
|
-
|
|
277
|
-
usedPath = candidate;
|
|
278
|
-
break;
|
|
279
|
-
}
|
|
280
|
-
if (!bootstrap && unboundBootstrap && unboundPath) {
|
|
281
|
-
bootstrap = unboundBootstrap;
|
|
282
|
-
usedPath = unboundPath;
|
|
306
|
+
usable.push(candidateBootstrap);
|
|
283
307
|
}
|
|
284
|
-
|
|
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
|
+
}
|
package/dist/lib/node-adapter.js
CHANGED
|
@@ -5,7 +5,8 @@ exports.clearAdapterCredentialOverride = clearAdapterCredentialOverride;
|
|
|
5
5
|
exports.getAdapterCredentialOverride = getAdapterCredentialOverride;
|
|
6
6
|
exports.createNodeAdapter = createNodeAdapter;
|
|
7
7
|
exports.__recomputeCredentialForTest = __recomputeCredentialForTest;
|
|
8
|
-
const
|
|
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.
|
package/dist/lib/triggers.js
CHANGED
|
@@ -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',
|
package/dist/lib/work-sync.d.ts
CHANGED
|
@@ -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';
|