@skrr-ai/cli 0.1.24 → 0.1.26
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 +11 -10
- package/dist/commands/code/install.js +13 -0
- package/dist/commands/harnesses/leases/billing.d.ts +9 -6
- package/dist/commands/harnesses/leases/billing.js +19 -24
- package/dist/commands/machines/dedicated/attach.d.ts +28 -0
- package/dist/commands/machines/dedicated/attach.js +75 -0
- package/dist/commands/machines/dedicated/catalog.d.ts +17 -0
- package/dist/commands/machines/dedicated/catalog.js +75 -0
- package/dist/commands/machines/dedicated/cp.d.ts +22 -0
- package/dist/commands/machines/dedicated/cp.js +76 -0
- package/dist/commands/machines/dedicated/create.d.ts +6 -2
- package/dist/commands/machines/dedicated/create.js +29 -20
- package/dist/commands/machines/dedicated/destroy.d.ts +6 -2
- package/dist/commands/machines/dedicated/destroy.js +41 -24
- package/dist/commands/machines/dedicated/exec.d.ts +28 -0
- package/dist/commands/machines/dedicated/exec.js +105 -0
- package/dist/commands/machines/dedicated/grow.d.ts +6 -2
- package/dist/commands/machines/dedicated/grow.js +14 -19
- package/dist/commands/machines/dedicated/health-check.d.ts +2 -2
- package/dist/commands/machines/dedicated/health-check.js +16 -14
- package/dist/commands/machines/dedicated/incident-close.d.ts +3 -2
- package/dist/commands/machines/dedicated/incident-close.js +11 -14
- package/dist/commands/machines/dedicated/index.js +37 -16
- package/dist/commands/machines/dedicated/list.d.ts +2 -2
- package/dist/commands/machines/dedicated/list.js +28 -17
- package/dist/commands/machines/dedicated/restart.d.ts +5 -2
- package/dist/commands/machines/dedicated/restart.js +8 -19
- package/dist/commands/machines/dedicated/show.d.ts +2 -2
- package/dist/commands/machines/dedicated/show.js +28 -30
- package/dist/commands/machines/dedicated/sign-in.d.ts +19 -0
- package/dist/commands/machines/dedicated/sign-in.js +49 -0
- package/dist/commands/machines/dedicated/snapshot.d.ts +5 -2
- package/dist/commands/machines/dedicated/snapshot.js +11 -19
- package/dist/commands/machines/dedicated/spending-limit.d.ts +22 -0
- package/dist/commands/machines/dedicated/spending-limit.js +50 -0
- package/dist/commands/machines/dedicated/start.d.ts +5 -2
- package/dist/commands/machines/dedicated/start.js +8 -19
- package/dist/commands/machines/dedicated/stop.d.ts +5 -2
- package/dist/commands/machines/dedicated/stop.js +8 -19
- package/dist/commands/machines/dedicated/terminal.d.ts +22 -0
- package/dist/commands/machines/dedicated/terminal.js +42 -0
- 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/commands/tasks/list.js +27 -0
- package/dist/lib/api-fetch.js +9 -1
- package/dist/lib/assignee-resolver.d.ts +7 -6
- package/dist/lib/daemonBroker.d.ts +28 -0
- package/dist/lib/daemonBroker.js +60 -19
- package/dist/lib/dedicated-copy.d.ts +22 -0
- package/dist/lib/dedicated-copy.js +115 -0
- package/dist/lib/dedicated-lease-command.d.ts +50 -0
- package/dist/lib/dedicated-lease-command.js +158 -0
- package/dist/lib/dedicated-machines.d.ts +248 -12
- package/dist/lib/dedicated-machines.js +385 -25
- package/dist/lib/dedicated-terminal.d.ts +89 -0
- package/dist/lib/dedicated-terminal.js +275 -0
- package/dist/lib/dedicated-wait.d.ts +72 -0
- package/dist/lib/dedicated-wait.js +195 -0
- package/dist/lib/harnesses.d.ts +6 -0
- 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 +13 -11
- package/dist/lib/sky-code.d.ts +30 -1
- package/dist/lib/sky-code.js +44 -2
- 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/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/index.js +2 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/kek/linux.js +71 -21
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.d.ts +10 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/cjs/spawnEnv.js +17 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.d.ts +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/index.js +1 -1
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/kek/linux.js +71 -21
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.d.ts +10 -0
- package/dist/node_modules/@skrr-ai/auth-core/dist/esm/spawnEnv.js +16 -0
- package/dist/node_modules/@skrr-ai/auth-core/package.json +1 -1
- package/dist/node_modules/@skrr-ai/data-provider/index.js +2657 -2596
- package/oclif.manifest.json +5075 -4294
- package/package.json +1 -1
|
@@ -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 ` +
|
|
@@ -86,6 +86,33 @@ function emptyTasksMessage(flags, bin = 'skrr') {
|
|
|
86
86
|
lines.push(' no longer has that status. Try `--execution-state active` (running now) or');
|
|
87
87
|
lines.push(` \`--execution-state recovered\` (reaped, startable), or \`${bin} tasks active\`.`);
|
|
88
88
|
}
|
|
89
|
+
else if (typeof flags.status === 'string' &&
|
|
90
|
+
flags.status.length > 0 &&
|
|
91
|
+
!flags['execution-state']) {
|
|
92
|
+
// A filter that matches nothing and a board that IS empty print the same
|
|
93
|
+
// thing, and the caller cannot tell which they are looking at. Observed:
|
|
94
|
+
// `--status todo` on a board whose open column is `backlog` said `No tasks.`
|
|
95
|
+
// while twelve were open, and it was believed.
|
|
96
|
+
//
|
|
97
|
+
// Not fixed by validating the value, for two reasons. A status is a
|
|
98
|
+
// Space-owned workflow-state id — TASK_STATUSES is a legacy default set, not
|
|
99
|
+
// a closed vocabulary — so a space may legitimately define one we have never
|
|
100
|
+
// heard of. And `todo` IS a built-in, so refusing unknown values would not
|
|
101
|
+
// have caught the case that prompted this. What distinguishes the two
|
|
102
|
+
// readings is naming the filter and how to drop it.
|
|
103
|
+
//
|
|
104
|
+
// Silent when `--execution-state` is also set, to leave the exact bare
|
|
105
|
+
// string the branch above deliberately produces for that combination. The
|
|
106
|
+
// same ambiguity exists there, but overturning a pinned decision is not
|
|
107
|
+
// this change's business.
|
|
108
|
+
lines.push('');
|
|
109
|
+
lines.push(`Note: nothing matched \`--status ${flags.status}\`. A status is a workflow-state id the`);
|
|
110
|
+
lines.push(' space owns, so a board need not use this one at all.');
|
|
111
|
+
lines.push(' → See what this scope actually holds: the same command without `--status`.');
|
|
112
|
+
if (!data_provider_1.TASK_STATUSES.includes(flags.status)) {
|
|
113
|
+
lines.push(` (built-in ids: ${data_provider_1.TASK_STATUSES.join(', ')})`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
89
116
|
return lines.join('\n');
|
|
90
117
|
}
|
|
91
118
|
/**
|
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 = {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
* liveness with `process.kill(pid, 0)` to filter stale files left behind
|
|
37
37
|
* by a force-killed daemon.
|
|
38
38
|
*/
|
|
39
|
+
import { type AuthBackend } from './auth-storage';
|
|
39
40
|
import { type CliConfig } from './config';
|
|
40
41
|
/**
|
|
41
42
|
* Wire-shape of the bootstrap file. Mirrors
|
|
@@ -86,6 +87,13 @@ export interface DaemonBrokerResult {
|
|
|
86
87
|
* the mint/confirm split, where the mint still revoked eagerly.
|
|
87
88
|
*/
|
|
88
89
|
familyId?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Where `attemptDaemonBrokerLoginAndPersist` actually stored the credential.
|
|
92
|
+
* Callers label the token's source from this, not from the platform: Keychain
|
|
93
|
+
* only exists on macOS, and even there a failed Keychain write falls back to
|
|
94
|
+
* the file.
|
|
95
|
+
*/
|
|
96
|
+
storedIn?: Exclude<AuthBackend, 'none'>;
|
|
89
97
|
}
|
|
90
98
|
/**
|
|
91
99
|
* Explicit reasons the broker path could fail. These exist to keep the
|
|
@@ -139,6 +147,8 @@ export interface DaemonBrokerOptions {
|
|
|
139
147
|
profile?: string;
|
|
140
148
|
/** Override the bootstrap file path entirely (tests / unusual layouts). */
|
|
141
149
|
bootstrapPathOverride?: string;
|
|
150
|
+
/** Test seam: the ordered candidate list, when one path is not enough. */
|
|
151
|
+
candidatePathsOverride?: readonly string[];
|
|
142
152
|
/** Override total request timeout. Tests use a short value. */
|
|
143
153
|
timeoutMs?: number;
|
|
144
154
|
}
|
|
@@ -159,6 +169,24 @@ export declare function resolveProfileBootstrapPaths(primary: string, entryNames
|
|
|
159
169
|
* deterministic when more than one matching daemon is running.
|
|
160
170
|
*/
|
|
161
171
|
export declare function resolveBootstrapCandidatePaths(profile?: string): string[];
|
|
172
|
+
/**
|
|
173
|
+
* A Dedicated Runtime guest's workload hand-off descriptor.
|
|
174
|
+
*
|
|
175
|
+
* On that product the daemon runs as `skrr-runtime` and everything the user runs
|
|
176
|
+
* — terminals, Bash tool calls, engines — as `skrr-workload`, so the island
|
|
177
|
+
* bootstrap above sits in a home this process cannot read. The daemon publishes
|
|
178
|
+
* this second descriptor to the shared workload group instead
|
|
179
|
+
* (`daemon/src/dedicated-cli-handoff.ts`). Its secret is accepted by the
|
|
180
|
+
* cli-handoff route and nothing else, so it is a hand-off candidate ONLY: it is
|
|
181
|
+
* deliberately absent from `resolveBootstrapCandidatePaths`, which message-intent
|
|
182
|
+
* submission also reads and which needs the island secret.
|
|
183
|
+
*
|
|
184
|
+
* Fixed and root-controlled (the daemon unit's RuntimeDirectory), so nothing the
|
|
185
|
+
* user configures can point the broker somewhere else.
|
|
186
|
+
*/
|
|
187
|
+
export declare const DEDICATED_RUNTIME_CLI_HANDOFF_DESCRIPTOR = "/run/skrr-dedicated-runtime/cli-handoff/descriptor.json";
|
|
188
|
+
/** Where the cli-handoff broker looks, in order. */
|
|
189
|
+
export declare function resolveCliHandoffCandidatePaths(profile?: string): string[];
|
|
162
190
|
/**
|
|
163
191
|
* Resolve the first live daemon bootstrap for a profile. Message-intent
|
|
164
192
|
* 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();
|
|
@@ -486,7 +527,7 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
|
|
|
486
527
|
if (!serverOrigin) {
|
|
487
528
|
throw new Error('Daemon-broker credential persistence requires a valid CLI baseURL.');
|
|
488
529
|
}
|
|
489
|
-
(0, auth_storage_1.writeToBackend)({
|
|
530
|
+
const storedIn = (0, auth_storage_1.writeToBackend)({
|
|
490
531
|
token: outcome.accessToken,
|
|
491
532
|
refreshToken: outcome.refreshToken,
|
|
492
533
|
...(expiryToEpochMs(outcome.accessExpiresAt) !== undefined
|
|
@@ -507,7 +548,7 @@ async function attemptDaemonBrokerLoginAndPersist(opts) {
|
|
|
507
548
|
familyId: outcome.familyId,
|
|
508
549
|
});
|
|
509
550
|
}
|
|
510
|
-
return outcome;
|
|
551
|
+
return storedIn === 'none' ? outcome : { ...outcome, storedIn };
|
|
511
552
|
}
|
|
512
553
|
// ---------------------------------------------------------------------
|
|
513
554
|
// Auto-broker on first command (BaseCommand.init hook)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readDedicatedRuntimeFileChunk, writeDedicatedRuntimeFileChunk, type DedicatedRuntimeScope } from './dedicated-machines';
|
|
2
|
+
/**
|
|
3
|
+
* The two copy loops behind `skrr machines dedicated cp`.
|
|
4
|
+
*
|
|
5
|
+
* One API request per 512 KiB chunk, so every chunk passes the same owner
|
|
6
|
+
* check, generation fence and daemon file policy as any other access to the
|
|
7
|
+
* machine. Neither end is replaced until the whole file has arrived: an upload
|
|
8
|
+
* stages beside its destination on the machine and the final chunk renames it;
|
|
9
|
+
* a download writes a temporary file beside its local destination and renames.
|
|
10
|
+
*/
|
|
11
|
+
export interface DedicatedCopyIo {
|
|
12
|
+
readChunk: typeof readDedicatedRuntimeFileChunk;
|
|
13
|
+
writeChunk: typeof writeDedicatedRuntimeFileChunk;
|
|
14
|
+
chunkBytes: number;
|
|
15
|
+
maxBytes: number;
|
|
16
|
+
}
|
|
17
|
+
export interface DedicatedCopyResult {
|
|
18
|
+
bytes: number;
|
|
19
|
+
destination: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function uploadToDedicatedRuntime(localPath: string, leaseId: string, remotePath: string, scope?: DedicatedRuntimeScope, io?: Partial<DedicatedCopyIo>): Promise<DedicatedCopyResult>;
|
|
22
|
+
export declare function downloadFromDedicatedRuntime(leaseId: string, remotePath: string, localPath: string, scope?: DedicatedRuntimeScope, io?: Partial<DedicatedCopyIo>): Promise<DedicatedCopyResult>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.uploadToDedicatedRuntime = uploadToDedicatedRuntime;
|
|
4
|
+
exports.downloadFromDedicatedRuntime = downloadFromDedicatedRuntime;
|
|
5
|
+
const node_crypto_1 = require("node:crypto");
|
|
6
|
+
const promises_1 = require("node:fs/promises");
|
|
7
|
+
const node_path_1 = require("node:path");
|
|
8
|
+
const dedicated_machines_1 = require("./dedicated-machines");
|
|
9
|
+
const defaultIo = {
|
|
10
|
+
readChunk: dedicated_machines_1.readDedicatedRuntimeFileChunk,
|
|
11
|
+
writeChunk: dedicated_machines_1.writeDedicatedRuntimeFileChunk,
|
|
12
|
+
chunkBytes: dedicated_machines_1.DEDICATED_FILE_CHUNK_BYTES,
|
|
13
|
+
maxBytes: dedicated_machines_1.DEDICATED_FILE_MAX_BYTES,
|
|
14
|
+
};
|
|
15
|
+
function mib(bytes) {
|
|
16
|
+
return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MiB`;
|
|
17
|
+
}
|
|
18
|
+
async function uploadToDedicatedRuntime(localPath, leaseId, remotePath, scope = {}, io = {}) {
|
|
19
|
+
const { writeChunk, chunkBytes, maxBytes } = { ...defaultIo, ...io };
|
|
20
|
+
const info = await (0, promises_1.stat)(localPath);
|
|
21
|
+
if (!info.isFile()) {
|
|
22
|
+
throw new Error(`${localPath} is not a regular file`);
|
|
23
|
+
}
|
|
24
|
+
if (info.size > maxBytes) {
|
|
25
|
+
throw new Error(`${localPath} is ${mib(info.size)}; copy is limited to ${mib(maxBytes)}. Push it through Git or object storage instead.`);
|
|
26
|
+
}
|
|
27
|
+
const transferId = `cp-${Date.now().toString(36)}-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`;
|
|
28
|
+
const handle = await (0, promises_1.open)(localPath, 'r');
|
|
29
|
+
try {
|
|
30
|
+
const buffer = Buffer.alloc(chunkBytes);
|
|
31
|
+
let offset = 0;
|
|
32
|
+
for (;;) {
|
|
33
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, offset);
|
|
34
|
+
const final = offset + bytesRead >= info.size;
|
|
35
|
+
if (!final && bytesRead === 0) {
|
|
36
|
+
throw new Error(`${localPath} shrank while it was being copied`);
|
|
37
|
+
}
|
|
38
|
+
const result = await writeChunk(leaseId, {
|
|
39
|
+
path: remotePath,
|
|
40
|
+
transferId,
|
|
41
|
+
offset,
|
|
42
|
+
dataBase64: buffer.subarray(0, bytesRead).toString('base64'),
|
|
43
|
+
// A file that GREW is caught here: the machine refuses to commit
|
|
44
|
+
// when what it staged is not the size declared at the start.
|
|
45
|
+
...(final ? { final: true, totalSize: info.size } : {}),
|
|
46
|
+
}, scope);
|
|
47
|
+
offset += bytesRead;
|
|
48
|
+
if (final) {
|
|
49
|
+
return { bytes: offset, destination: `${leaseId}:${result.path || remotePath}` };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
// Best effort: remove the staged partial so it does not sit beside the
|
|
55
|
+
// destination. The destination itself was never touched.
|
|
56
|
+
await writeChunk(leaseId, { path: remotePath, transferId, offset: 0, abort: true }, scope).catch(() => undefined);
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
await handle.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function downloadFromDedicatedRuntime(leaseId, remotePath, localPath, scope = {}, io = {}) {
|
|
64
|
+
const { readChunk, chunkBytes } = { ...defaultIo, ...io };
|
|
65
|
+
let target = localPath;
|
|
66
|
+
try {
|
|
67
|
+
if ((await (0, promises_1.stat)(localPath)).isDirectory()) {
|
|
68
|
+
target = (0, node_path_1.join)(localPath, (0, node_path_1.basename)(remotePath));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Does not exist yet: it names the file.
|
|
73
|
+
}
|
|
74
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(target), { recursive: true });
|
|
75
|
+
const temporary = (0, node_path_1.join)((0, node_path_1.dirname)(target), `.${(0, node_path_1.basename)(target)}.skrr-download-${(0, node_crypto_1.randomBytes)(6).toString('hex')}`);
|
|
76
|
+
const handle = await (0, promises_1.open)(temporary, 'wx');
|
|
77
|
+
let closed = false;
|
|
78
|
+
let offset = 0;
|
|
79
|
+
let size = -1;
|
|
80
|
+
let mtimeMs = -1;
|
|
81
|
+
try {
|
|
82
|
+
for (;;) {
|
|
83
|
+
const chunk = await readChunk(leaseId, { path: remotePath, offset, length: chunkBytes }, scope);
|
|
84
|
+
if (size === -1) {
|
|
85
|
+
size = chunk.size;
|
|
86
|
+
mtimeMs = chunk.mtimeMs;
|
|
87
|
+
}
|
|
88
|
+
else if (chunk.size !== size || chunk.mtimeMs !== mtimeMs) {
|
|
89
|
+
// Chunks from two versions of a file splice into one that never existed.
|
|
90
|
+
throw new Error(`${remotePath} changed on the machine while it was being copied`);
|
|
91
|
+
}
|
|
92
|
+
const bytes = Buffer.from(chunk.dataBase64, 'base64');
|
|
93
|
+
if (bytes.byteLength !== chunk.bytesRead) {
|
|
94
|
+
throw new Error('A chunk arrived damaged; nothing was written to the destination');
|
|
95
|
+
}
|
|
96
|
+
await handle.write(bytes, 0, bytes.byteLength, offset);
|
|
97
|
+
offset += bytes.byteLength;
|
|
98
|
+
if (chunk.eof)
|
|
99
|
+
break;
|
|
100
|
+
if (bytes.byteLength === 0) {
|
|
101
|
+
throw new Error(`${remotePath} ended before its reported size`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
await handle.close();
|
|
105
|
+
closed = true;
|
|
106
|
+
await (0, promises_1.rename)(temporary, target);
|
|
107
|
+
return { bytes: offset, destination: target };
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
if (!closed)
|
|
111
|
+
await handle.close().catch(() => undefined);
|
|
112
|
+
await (0, promises_1.unlink)(temporary).catch(() => undefined);
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { BaseCommand } from '../base-command';
|
|
2
|
+
import { type DedicatedRuntimeResponse, type DedicatedRuntimeScope } from './dedicated-machines';
|
|
3
|
+
import { type DedicatedWaitGoal } from './dedicated-wait';
|
|
4
|
+
/**
|
|
5
|
+
* Lifecycle calls answer 202 with the lease already in its transitional state.
|
|
6
|
+
* `--wait` watches it to the END of the operation, which for snapshot and grow
|
|
7
|
+
* is a field, not a state (see `dedicated-wait.ts`).
|
|
8
|
+
*/
|
|
9
|
+
export declare const dedicatedWaitFlags: {
|
|
10
|
+
wait: import("@oclif/core/lib/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
timeout: import("@oclif/core/lib/interfaces").OptionFlag<string | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
12
|
+
interval: import("@oclif/core/lib/interfaces").OptionFlag<number | undefined, import("@oclif/core/lib/interfaces").CustomOptions>;
|
|
13
|
+
};
|
|
14
|
+
export declare abstract class DedicatedLeaseCommand extends BaseCommand {
|
|
15
|
+
/**
|
|
16
|
+
* `--workspace` is a BaseCommand flag every command already accepts (it sets
|
|
17
|
+
* `X-Workspace-Id` for the whole invocation). It is also passed as the scope
|
|
18
|
+
* query, the way `create`, `exec` and `cp` always have, so the scope a request
|
|
19
|
+
* was made in is visible in the request itself.
|
|
20
|
+
*/
|
|
21
|
+
protected scopeFrom(flags: {
|
|
22
|
+
workspace?: string;
|
|
23
|
+
}): DedicatedRuntimeScope;
|
|
24
|
+
/**
|
|
25
|
+
* Open an interactive terminal on a lease's machine and run it to the end,
|
|
26
|
+
* exiting with the shell's own code.
|
|
27
|
+
*
|
|
28
|
+
* Every refusal is decided BEFORE the socket opens where it can be — a stopped
|
|
29
|
+
* machine, a daemon that is not connected, stdin that is not a terminal — so
|
|
30
|
+
* the caller gets a sentence that says what to do instead of a start that
|
|
31
|
+
* hangs and times out.
|
|
32
|
+
*/
|
|
33
|
+
protected openDedicatedTerminal(leaseId: string, flags: {
|
|
34
|
+
workspace?: string;
|
|
35
|
+
cwd?: string;
|
|
36
|
+
}, initialCommand?: string): Promise<void>;
|
|
37
|
+
/** Run an API call, turning a refusal into the server's own sentence. */
|
|
38
|
+
protected callDedicated<T>(fn: () => Promise<T>): Promise<T>;
|
|
39
|
+
/**
|
|
40
|
+
* Print the lease an action returned, or wait for its operation to finish
|
|
41
|
+
* first when `--wait` was passed.
|
|
42
|
+
*/
|
|
43
|
+
protected reportLeaseAction(response: DedicatedRuntimeResponse | undefined, flags: {
|
|
44
|
+
json?: boolean;
|
|
45
|
+
wait?: boolean;
|
|
46
|
+
timeout?: string;
|
|
47
|
+
interval?: number;
|
|
48
|
+
workspace?: string;
|
|
49
|
+
}, goal: DedicatedWaitGoal | null, verb: string): Promise<void>;
|
|
50
|
+
}
|