@onklave/agent-cli 0.1.11 → 0.1.13
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/main.js +1353 -12
- package/package.json +1 -1
package/main.js
CHANGED
|
@@ -96,7 +96,7 @@ var AuthService = class {
|
|
|
96
96
|
/*
|
|
97
97
|
* Save the device token received during machine registration.
|
|
98
98
|
*/
|
|
99
|
-
async saveDeviceToken(deviceToken, machineId) {
|
|
99
|
+
async saveDeviceToken(deviceToken, machineId, deviceTokenExpiresAt) {
|
|
100
100
|
this.ensureConfigDir();
|
|
101
101
|
const existing = await this.getCredentials();
|
|
102
102
|
if (!existing) {
|
|
@@ -107,7 +107,8 @@ var AuthService = class {
|
|
|
107
107
|
const credentials = {
|
|
108
108
|
...existing,
|
|
109
109
|
deviceToken,
|
|
110
|
-
machineId
|
|
110
|
+
machineId,
|
|
111
|
+
...deviceTokenExpiresAt ? { deviceTokenExpiresAt } : {}
|
|
111
112
|
};
|
|
112
113
|
fs.writeFileSync(
|
|
113
114
|
this.credentialsPath,
|
|
@@ -554,13 +555,41 @@ var PlatformClient = class {
|
|
|
554
555
|
* Register this machine as an agent runner.
|
|
555
556
|
*/
|
|
556
557
|
async registerMachine(linkingToken, metadata) {
|
|
558
|
+
return this.request("POST", "/api/v1/runner/register", {
|
|
559
|
+
linkingToken,
|
|
560
|
+
metadata
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
/*
|
|
564
|
+
* V6-CLI-002 Phase 6 — refresh the device token without re-running
|
|
565
|
+
* the linking-token flow. Caller authenticates with the current
|
|
566
|
+
* device token; backend issues a fresh one with extended expiry.
|
|
567
|
+
*/
|
|
568
|
+
async refreshDeviceToken(currentDeviceToken) {
|
|
569
|
+
return this.request("POST", "/api/v1/runner/register/refresh", void 0, {
|
|
570
|
+
"x-device-token": currentDeviceToken
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/*
|
|
574
|
+
* V6-CLI-002 Phase 5b — claim an assignment as the registered runner.
|
|
575
|
+
*/
|
|
576
|
+
async claimAssignment(assignmentId, machineId, deviceToken) {
|
|
557
577
|
return this.request(
|
|
558
578
|
"POST",
|
|
559
|
-
|
|
560
|
-
{
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
579
|
+
`/api/v1/runner/assignments/${assignmentId}/claim`,
|
|
580
|
+
{ machineId },
|
|
581
|
+
{ "x-device-token": deviceToken }
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
/*
|
|
585
|
+
* V6-CLI-002 Phase 5c — release a previously claimed assignment.
|
|
586
|
+
*/
|
|
587
|
+
async releaseAssignment(assignmentId, machineId, reason, deviceToken) {
|
|
588
|
+
return this.request(
|
|
589
|
+
"POST",
|
|
590
|
+
`/api/v1/runner/assignments/${assignmentId}/release`,
|
|
591
|
+
{ machineId, reason },
|
|
592
|
+
{ "x-device-token": deviceToken }
|
|
564
593
|
);
|
|
565
594
|
}
|
|
566
595
|
/*
|
|
@@ -604,12 +633,13 @@ var PlatformClient = class {
|
|
|
604
633
|
/*
|
|
605
634
|
* Make an authenticated HTTP request to the platform.
|
|
606
635
|
*/
|
|
607
|
-
async request(method,
|
|
608
|
-
const url = `${this.baseUrl}${
|
|
636
|
+
async request(method, path7, body, extraHeaders) {
|
|
637
|
+
const url = `${this.baseUrl}${path7}`;
|
|
609
638
|
const headers = {
|
|
610
639
|
Authorization: `Bearer ${this.token}`,
|
|
611
640
|
"Content-Type": "application/json",
|
|
612
|
-
Accept: "application/json"
|
|
641
|
+
Accept: "application/json",
|
|
642
|
+
...extraHeaders ?? {}
|
|
613
643
|
};
|
|
614
644
|
const options = {
|
|
615
645
|
method,
|
|
@@ -908,6 +938,26 @@ var SessionManager = class {
|
|
|
908
938
|
}
|
|
909
939
|
};
|
|
910
940
|
|
|
941
|
+
// _apps/@onklave/agent-cli/src/types/events.types.ts
|
|
942
|
+
var DAEMON_AUDIT_ACTIONS = {
|
|
943
|
+
STARTED: "daemon.started",
|
|
944
|
+
ONLINE: "daemon.online",
|
|
945
|
+
DRAINING: "daemon.draining",
|
|
946
|
+
STOPPED: "daemon.stopped",
|
|
947
|
+
FORCE_STOPPED: "daemon.force_stopped",
|
|
948
|
+
DRAIN_TIMEOUT_EXCEEDED: "daemon.drain_timeout_exceeded",
|
|
949
|
+
AUTH_WARNING: "daemon.auth_warning",
|
|
950
|
+
AUTH_EXPIRED: "daemon.auth_expired",
|
|
951
|
+
HEARTBEAT_FAILED: "daemon.heartbeat_failed",
|
|
952
|
+
RESOURCE_LIMIT_BREACHED: "daemon.resource_limit_breached",
|
|
953
|
+
SESSION_CLAIMED: "daemon.session_claimed",
|
|
954
|
+
SESSION_COMPLETED: "daemon.session_completed",
|
|
955
|
+
SESSION_FAILED: "daemon.session_failed",
|
|
956
|
+
UPDATE_AVAILABLE: "daemon.update_available",
|
|
957
|
+
UPDATE_APPLIED: "daemon.update_applied",
|
|
958
|
+
UPDATE_FAILED: "daemon.update_failed"
|
|
959
|
+
};
|
|
960
|
+
|
|
911
961
|
// _apps/@onklave/agent-cli/src/services/state-publisher.ts
|
|
912
962
|
var StatePublisher = class {
|
|
913
963
|
constructor(commsClient) {
|
|
@@ -2775,7 +2825,11 @@ Machine ${creds.machineId} updated successfully.`);
|
|
|
2775
2825
|
linkingToken,
|
|
2776
2826
|
metadata
|
|
2777
2827
|
);
|
|
2778
|
-
await authService.saveDeviceToken(
|
|
2828
|
+
await authService.saveDeviceToken(
|
|
2829
|
+
result.deviceToken,
|
|
2830
|
+
result.machineId,
|
|
2831
|
+
result.deviceTokenExpiresAt
|
|
2832
|
+
);
|
|
2779
2833
|
console.log(`
|
|
2780
2834
|
Machine registered successfully.`);
|
|
2781
2835
|
console.log(` Machine ID: ${result.machineId}`);
|
|
@@ -2846,6 +2900,1292 @@ async function logsCommand(args) {
|
|
|
2846
2900
|
}
|
|
2847
2901
|
}
|
|
2848
2902
|
|
|
2903
|
+
// _apps/@onklave/agent-cli/src/commands/daemon.command.ts
|
|
2904
|
+
import * as fs6 from "fs";
|
|
2905
|
+
|
|
2906
|
+
// _apps/@onklave/agent-cli/src/services/daemon-comms.service.ts
|
|
2907
|
+
var DaemonCommsService = class {
|
|
2908
|
+
constructor(opts, client) {
|
|
2909
|
+
this.disconnectedAt = null;
|
|
2910
|
+
this.stopRequested = false;
|
|
2911
|
+
this.client = client ?? new CommsClient();
|
|
2912
|
+
this.opts = opts;
|
|
2913
|
+
this.sleep = opts.sleep ?? defaultSleep;
|
|
2914
|
+
this.onRetry = opts.onRetry ?? ((attempt, delayMs, err) => console.warn(
|
|
2915
|
+
`[daemon-comms] reconnect attempt ${attempt} after ${delayMs}ms (${err.message})`
|
|
2916
|
+
));
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Opens the socket connection. Retries indefinitely with jittered
|
|
2920
|
+
* exponential backoff until either (a) a connect succeeds or (b)
|
|
2921
|
+
* `stop()` is called. Throws only if `stop()` is invoked before any
|
|
2922
|
+
* successful connect.
|
|
2923
|
+
*/
|
|
2924
|
+
async connect() {
|
|
2925
|
+
let attempt = 0;
|
|
2926
|
+
while (!this.stopRequested) {
|
|
2927
|
+
try {
|
|
2928
|
+
await this.client.connect(this.opts.platformUrl, this.opts.token, {
|
|
2929
|
+
machineId: this.opts.machineId,
|
|
2930
|
+
deviceToken: this.opts.deviceToken,
|
|
2931
|
+
orgId: this.opts.orgId
|
|
2932
|
+
});
|
|
2933
|
+
this.disconnectedAt = null;
|
|
2934
|
+
return;
|
|
2935
|
+
} catch (err) {
|
|
2936
|
+
attempt += 1;
|
|
2937
|
+
const delay = this.computeBackoff(attempt);
|
|
2938
|
+
this.onRetry(attempt, delay, err);
|
|
2939
|
+
if (this.disconnectedAt == null) this.disconnectedAt = /* @__PURE__ */ new Date();
|
|
2940
|
+
await this.sleep(delay);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
throw new Error("daemon-comms: stop() called before any connect succeeded");
|
|
2944
|
+
}
|
|
2945
|
+
/**
|
|
2946
|
+
* Signal that no further reconnect should be attempted and close the
|
|
2947
|
+
* underlying socket cleanly. Idempotent.
|
|
2948
|
+
*/
|
|
2949
|
+
disconnect() {
|
|
2950
|
+
this.stopRequested = true;
|
|
2951
|
+
this.client.disconnect();
|
|
2952
|
+
}
|
|
2953
|
+
inner() {
|
|
2954
|
+
return this.client;
|
|
2955
|
+
}
|
|
2956
|
+
isConnected() {
|
|
2957
|
+
return this.client.isConnected();
|
|
2958
|
+
}
|
|
2959
|
+
/**
|
|
2960
|
+
* Continuous-disconnect duration in ms (null if currently connected).
|
|
2961
|
+
* Per spec §3.3, when this exceeds 120s the daemon's `status` output
|
|
2962
|
+
* should report `offline` — the platform will have done so via
|
|
2963
|
+
* heartbeat staleness anyway.
|
|
2964
|
+
*/
|
|
2965
|
+
disconnectedForMs() {
|
|
2966
|
+
if (!this.disconnectedAt) return null;
|
|
2967
|
+
return Date.now() - this.disconnectedAt.getTime();
|
|
2968
|
+
}
|
|
2969
|
+
computeBackoff(attempt) {
|
|
2970
|
+
const max = this.opts.maxBackoffMs ?? 6e4;
|
|
2971
|
+
const base = Math.min(max, 1e3 * 2 ** (attempt - 1));
|
|
2972
|
+
const j = this.opts.jitter ?? 0.2;
|
|
2973
|
+
const swing = base * j;
|
|
2974
|
+
return Math.round(base + (Math.random() * 2 - 1) * swing);
|
|
2975
|
+
}
|
|
2976
|
+
};
|
|
2977
|
+
function defaultSleep(ms) {
|
|
2978
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
// _apps/@onklave/agent-cli/src/services/daemon-claim-handler.service.ts
|
|
2982
|
+
var DaemonClaimHandler = class {
|
|
2983
|
+
constructor(opts) {
|
|
2984
|
+
this.activeSessions = 0;
|
|
2985
|
+
this.machineId = opts.machineId;
|
|
2986
|
+
this.audit = opts.audit;
|
|
2987
|
+
this.maxConcurrentSessions = opts.maxConcurrentSessions ?? 2;
|
|
2988
|
+
this.spawnRunner = opts.spawnRunner ?? defaultSpawnStub;
|
|
2989
|
+
this.platformClient = opts.platformClient;
|
|
2990
|
+
this.deviceToken = opts.deviceToken;
|
|
2991
|
+
this.resourceSampler = opts.resourceSampler;
|
|
2992
|
+
this.maxCpuPercent = opts.maxCpuPercent ?? 80;
|
|
2993
|
+
this.maxMemoryBytes = opts.maxMemoryBytes;
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Wire up the inbound event subscriptions. Call after the socket has
|
|
2997
|
+
* connected — CommsClient `onSpawnRequest` / `onAssignmentAvailable`
|
|
2998
|
+
* register handlers via `this.socket?.on(...)` which is a no-op when
|
|
2999
|
+
* the socket is null.
|
|
3000
|
+
*/
|
|
3001
|
+
attachToSocket(comms) {
|
|
3002
|
+
comms.onSpawnRequest((req) => {
|
|
3003
|
+
void this.handleSpawn(req);
|
|
3004
|
+
});
|
|
3005
|
+
comms.onAssignmentAvailable((event) => {
|
|
3006
|
+
void this.handleAssignmentAvailable(event);
|
|
3007
|
+
});
|
|
3008
|
+
}
|
|
3009
|
+
getActiveSessionCount() {
|
|
3010
|
+
return this.activeSessions;
|
|
3011
|
+
}
|
|
3012
|
+
getMaxConcurrentSessions() {
|
|
3013
|
+
return this.maxConcurrentSessions;
|
|
3014
|
+
}
|
|
3015
|
+
canAcceptNew() {
|
|
3016
|
+
if (this.activeSessions >= this.maxConcurrentSessions) return false;
|
|
3017
|
+
const breach = this.checkResourceLimits();
|
|
3018
|
+
return breach == null;
|
|
3019
|
+
}
|
|
3020
|
+
/**
|
|
3021
|
+
* Returns the resource limit that would be breached by accepting a
|
|
3022
|
+
* new claim, or null if all limits are within bounds. Exposed so
|
|
3023
|
+
* `daemon status` can surface which limit is the active gate.
|
|
3024
|
+
*/
|
|
3025
|
+
checkResourceLimits() {
|
|
3026
|
+
if (!this.resourceSampler) return null;
|
|
3027
|
+
const snap = this.resourceSampler.snapshot();
|
|
3028
|
+
if (snap.sampleCount === 0) return null;
|
|
3029
|
+
if (snap.cpuPercent > this.maxCpuPercent) {
|
|
3030
|
+
return {
|
|
3031
|
+
limit: "cpu",
|
|
3032
|
+
current: snap.cpuPercent,
|
|
3033
|
+
max: this.maxCpuPercent
|
|
3034
|
+
};
|
|
3035
|
+
}
|
|
3036
|
+
if (this.maxMemoryBytes != null && snap.memoryRssBytes > this.maxMemoryBytes) {
|
|
3037
|
+
return {
|
|
3038
|
+
limit: "memory",
|
|
3039
|
+
current: snap.memoryRssBytes,
|
|
3040
|
+
max: this.maxMemoryBytes
|
|
3041
|
+
};
|
|
3042
|
+
}
|
|
3043
|
+
return null;
|
|
3044
|
+
}
|
|
3045
|
+
/**
|
|
3046
|
+
* Test-and-runtime hook: process an `agent:spawn` payload as if it had
|
|
3047
|
+
* arrived over the socket. Exposed for unit tests and for the
|
|
3048
|
+
* future re-emit-after-claim path; the live wiring is in the
|
|
3049
|
+
* constructor's `onSpawnRequest` registration.
|
|
3050
|
+
*/
|
|
3051
|
+
async handleSpawn(req) {
|
|
3052
|
+
if (this.activeSessions >= this.maxConcurrentSessions) {
|
|
3053
|
+
this.emitResourceLimitBreached(req.sessionId, "agent:spawn", {
|
|
3054
|
+
limit: "sessions",
|
|
3055
|
+
current: this.activeSessions,
|
|
3056
|
+
max: this.maxConcurrentSessions
|
|
3057
|
+
});
|
|
3058
|
+
return;
|
|
3059
|
+
}
|
|
3060
|
+
const resourceBreach = this.checkResourceLimits();
|
|
3061
|
+
if (resourceBreach) {
|
|
3062
|
+
this.emitResourceLimitBreached(
|
|
3063
|
+
req.sessionId,
|
|
3064
|
+
"agent:spawn",
|
|
3065
|
+
resourceBreach
|
|
3066
|
+
);
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
this.activeSessions += 1;
|
|
3070
|
+
this.audit.record({
|
|
3071
|
+
sessionId: req.sessionId,
|
|
3072
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3073
|
+
action: DAEMON_AUDIT_ACTIONS.SESSION_CLAIMED,
|
|
3074
|
+
details: {
|
|
3075
|
+
machineId: this.machineId,
|
|
3076
|
+
orgId: req.orgId,
|
|
3077
|
+
task: truncate2(req.task, 120)
|
|
3078
|
+
},
|
|
3079
|
+
outcome: "success"
|
|
3080
|
+
});
|
|
3081
|
+
try {
|
|
3082
|
+
await this.spawnRunner(req);
|
|
3083
|
+
this.audit.record({
|
|
3084
|
+
sessionId: req.sessionId,
|
|
3085
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3086
|
+
action: DAEMON_AUDIT_ACTIONS.SESSION_COMPLETED,
|
|
3087
|
+
details: { machineId: this.machineId },
|
|
3088
|
+
outcome: "success"
|
|
3089
|
+
});
|
|
3090
|
+
} catch (err) {
|
|
3091
|
+
this.audit.record({
|
|
3092
|
+
sessionId: req.sessionId,
|
|
3093
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3094
|
+
action: DAEMON_AUDIT_ACTIONS.SESSION_FAILED,
|
|
3095
|
+
details: {
|
|
3096
|
+
machineId: this.machineId,
|
|
3097
|
+
error: err.message
|
|
3098
|
+
},
|
|
3099
|
+
outcome: "failure"
|
|
3100
|
+
});
|
|
3101
|
+
} finally {
|
|
3102
|
+
this.activeSessions -= 1;
|
|
3103
|
+
}
|
|
3104
|
+
}
|
|
3105
|
+
async handleAssignmentAvailable(event) {
|
|
3106
|
+
if (this.activeSessions >= this.maxConcurrentSessions) {
|
|
3107
|
+
this.emitResourceLimitBreached(
|
|
3108
|
+
event.assignmentId,
|
|
3109
|
+
"assignment:claim-available",
|
|
3110
|
+
{
|
|
3111
|
+
limit: "sessions",
|
|
3112
|
+
current: this.activeSessions,
|
|
3113
|
+
max: this.maxConcurrentSessions
|
|
3114
|
+
}
|
|
3115
|
+
);
|
|
3116
|
+
return;
|
|
3117
|
+
}
|
|
3118
|
+
const resourceBreach = this.checkResourceLimits();
|
|
3119
|
+
if (resourceBreach) {
|
|
3120
|
+
this.emitResourceLimitBreached(
|
|
3121
|
+
event.assignmentId,
|
|
3122
|
+
"assignment:claim-available",
|
|
3123
|
+
resourceBreach
|
|
3124
|
+
);
|
|
3125
|
+
return;
|
|
3126
|
+
}
|
|
3127
|
+
if (!this.platformClient || !this.deviceToken) {
|
|
3128
|
+
console.log(
|
|
3129
|
+
`[daemon] assignment:claim-available assignmentId=${event.assignmentId} (platformClient not wired \u2014 Phase 5b config missing)`
|
|
3130
|
+
);
|
|
3131
|
+
return;
|
|
3132
|
+
}
|
|
3133
|
+
try {
|
|
3134
|
+
const result = await this.platformClient.claimAssignment(
|
|
3135
|
+
event.assignmentId,
|
|
3136
|
+
this.machineId,
|
|
3137
|
+
this.deviceToken
|
|
3138
|
+
);
|
|
3139
|
+
this.audit.record({
|
|
3140
|
+
sessionId: event.assignmentId,
|
|
3141
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3142
|
+
action: DAEMON_AUDIT_ACTIONS.SESSION_CLAIMED,
|
|
3143
|
+
details: {
|
|
3144
|
+
machineId: this.machineId,
|
|
3145
|
+
orgId: event.orgId,
|
|
3146
|
+
workItemId: event.workItemId,
|
|
3147
|
+
claimedAt: result.claimedAt,
|
|
3148
|
+
via: "assignment:claim-available"
|
|
3149
|
+
},
|
|
3150
|
+
outcome: "success"
|
|
3151
|
+
});
|
|
3152
|
+
} catch (err) {
|
|
3153
|
+
this.audit.record({
|
|
3154
|
+
sessionId: event.assignmentId,
|
|
3155
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3156
|
+
action: DAEMON_AUDIT_ACTIONS.SESSION_FAILED,
|
|
3157
|
+
details: {
|
|
3158
|
+
machineId: this.machineId,
|
|
3159
|
+
phase: "claim",
|
|
3160
|
+
error: err.message
|
|
3161
|
+
},
|
|
3162
|
+
outcome: "failure"
|
|
3163
|
+
});
|
|
3164
|
+
}
|
|
3165
|
+
}
|
|
3166
|
+
/**
|
|
3167
|
+
* Phase 5c — release a previously claimed assignment when the
|
|
3168
|
+
* daemon cannot proceed (e.g. drain interrupted, capacity check
|
|
3169
|
+
* failed mid-flight). Best-effort: failures here are logged but
|
|
3170
|
+
* the daemon's local state already moved on.
|
|
3171
|
+
*/
|
|
3172
|
+
async releaseClaim(assignmentId, reason) {
|
|
3173
|
+
if (!this.platformClient || !this.deviceToken) return;
|
|
3174
|
+
try {
|
|
3175
|
+
await this.platformClient.releaseAssignment(
|
|
3176
|
+
assignmentId,
|
|
3177
|
+
this.machineId,
|
|
3178
|
+
reason,
|
|
3179
|
+
this.deviceToken
|
|
3180
|
+
);
|
|
3181
|
+
} catch (err) {
|
|
3182
|
+
console.warn(
|
|
3183
|
+
`[daemon] release ${assignmentId} failed: ${err.message}`
|
|
3184
|
+
);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
emitResourceLimitBreached(refId, trigger, breach) {
|
|
3188
|
+
this.audit.record({
|
|
3189
|
+
sessionId: refId,
|
|
3190
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3191
|
+
action: DAEMON_AUDIT_ACTIONS.RESOURCE_LIMIT_BREACHED,
|
|
3192
|
+
details: {
|
|
3193
|
+
machineId: this.machineId,
|
|
3194
|
+
trigger,
|
|
3195
|
+
limit: breach.limit,
|
|
3196
|
+
current: breach.current,
|
|
3197
|
+
max: breach.max,
|
|
3198
|
+
activeSessions: this.activeSessions,
|
|
3199
|
+
maxConcurrentSessions: this.maxConcurrentSessions
|
|
3200
|
+
},
|
|
3201
|
+
outcome: "failure"
|
|
3202
|
+
});
|
|
3203
|
+
}
|
|
3204
|
+
};
|
|
3205
|
+
async function defaultSpawnStub(request) {
|
|
3206
|
+
console.log(
|
|
3207
|
+
`[daemon] STUB: would spawn session ${request.sessionId} for task: ${truncate2(request.task, 80)}`
|
|
3208
|
+
);
|
|
3209
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
3210
|
+
}
|
|
3211
|
+
function truncate2(s, n) {
|
|
3212
|
+
if (!s) return s;
|
|
3213
|
+
return s.length <= n ? s : `${s.slice(0, n - 1)}\u2026`;
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
// _apps/@onklave/agent-cli/src/services/daemon-resource-sampler.service.ts
|
|
3217
|
+
import * as os4 from "os";
|
|
3218
|
+
var DEFAULT_SAMPLE_INTERVAL_MS = 5e3;
|
|
3219
|
+
var DEFAULT_WINDOW_MS = 6e4;
|
|
3220
|
+
var DaemonResourceSampler = class {
|
|
3221
|
+
constructor(opts = {}) {
|
|
3222
|
+
this.samples = [];
|
|
3223
|
+
this.timer = null;
|
|
3224
|
+
this.sampleIntervalMs = opts.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS;
|
|
3225
|
+
this.windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
|
|
3226
|
+
this.cpuSource = opts.cpuSource ?? defaultCpuPercent;
|
|
3227
|
+
this.memorySource = opts.memorySource ?? defaultMemoryRss;
|
|
3228
|
+
}
|
|
3229
|
+
/**
|
|
3230
|
+
* Start the periodic sampler. Idempotent — second start is a no-op.
|
|
3231
|
+
* The timer is `unref()`'d so a daemon that has nothing else to do
|
|
3232
|
+
* (no in-flight sessions, no inbound events) does not stay alive only
|
|
3233
|
+
* because of the sampler.
|
|
3234
|
+
*/
|
|
3235
|
+
start() {
|
|
3236
|
+
if (this.timer) return;
|
|
3237
|
+
this.sample();
|
|
3238
|
+
this.timer = setInterval(() => this.sample(), this.sampleIntervalMs);
|
|
3239
|
+
this.timer.unref?.();
|
|
3240
|
+
}
|
|
3241
|
+
stop() {
|
|
3242
|
+
if (this.timer) {
|
|
3243
|
+
clearInterval(this.timer);
|
|
3244
|
+
this.timer = null;
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
/**
|
|
3248
|
+
* Returns the windowed average of CPU% and the latest memory RSS.
|
|
3249
|
+
* Memory is *not* averaged — the RSS at sample time is what matters
|
|
3250
|
+
* for "can we fit another session right now".
|
|
3251
|
+
*/
|
|
3252
|
+
snapshot() {
|
|
3253
|
+
if (this.samples.length === 0) {
|
|
3254
|
+
return {
|
|
3255
|
+
cpuPercent: 0,
|
|
3256
|
+
memoryRssBytes: 0,
|
|
3257
|
+
sampleCount: 0,
|
|
3258
|
+
windowMs: this.windowMs
|
|
3259
|
+
};
|
|
3260
|
+
}
|
|
3261
|
+
const cpuAvg = this.samples.reduce((sum, s) => sum + s.cpuPercent, 0) / this.samples.length;
|
|
3262
|
+
const memLatest = this.samples[this.samples.length - 1].memoryRssBytes;
|
|
3263
|
+
return {
|
|
3264
|
+
cpuPercent: cpuAvg,
|
|
3265
|
+
memoryRssBytes: memLatest,
|
|
3266
|
+
sampleCount: this.samples.length,
|
|
3267
|
+
windowMs: this.windowMs
|
|
3268
|
+
};
|
|
3269
|
+
}
|
|
3270
|
+
/** Take one sample now. Exposed for tests; called internally by start. */
|
|
3271
|
+
sample() {
|
|
3272
|
+
const now = Date.now();
|
|
3273
|
+
this.samples.push({
|
|
3274
|
+
at: now,
|
|
3275
|
+
cpuPercent: this.cpuSource(),
|
|
3276
|
+
memoryRssBytes: this.memorySource()
|
|
3277
|
+
});
|
|
3278
|
+
const cutoff = now - this.windowMs;
|
|
3279
|
+
while (this.samples.length > 0 && this.samples[0].at < cutoff) {
|
|
3280
|
+
this.samples.shift();
|
|
3281
|
+
}
|
|
3282
|
+
}
|
|
3283
|
+
};
|
|
3284
|
+
function defaultCpuPercent() {
|
|
3285
|
+
const cpus2 = os4.cpus().length || 1;
|
|
3286
|
+
const load1m = os4.loadavg()[0];
|
|
3287
|
+
return load1m / cpus2 * 100;
|
|
3288
|
+
}
|
|
3289
|
+
function defaultMemoryRss() {
|
|
3290
|
+
return process.memoryUsage().rss;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// _apps/@onklave/agent-cli/src/services/daemon-spawner.service.ts
|
|
3294
|
+
var DaemonSpawner = class {
|
|
3295
|
+
constructor(opts) {
|
|
3296
|
+
this.sessionManager = opts.sessionManager ?? new SessionManager();
|
|
3297
|
+
this.platformUrl = opts.platformUrl;
|
|
3298
|
+
}
|
|
3299
|
+
async runSpawnRequest(req) {
|
|
3300
|
+
const config = {
|
|
3301
|
+
task: req.task,
|
|
3302
|
+
context: req.context,
|
|
3303
|
+
persona: req.persona,
|
|
3304
|
+
workflow: req.workflow,
|
|
3305
|
+
model: req.model,
|
|
3306
|
+
timeout: req.timeout,
|
|
3307
|
+
guardrails: [],
|
|
3308
|
+
allowedTools: [],
|
|
3309
|
+
deniedTools: [],
|
|
3310
|
+
apiKey: req.apiKey ?? null,
|
|
3311
|
+
headless: true,
|
|
3312
|
+
platformUrl: this.platformUrl,
|
|
3313
|
+
orgId: req.orgId,
|
|
3314
|
+
systemPromptAppend: null
|
|
3315
|
+
};
|
|
3316
|
+
return new Promise((resolve3, reject) => {
|
|
3317
|
+
void this.sessionManager.spawnSession(req.sessionId, config, {
|
|
3318
|
+
onStdout: (data) => {
|
|
3319
|
+
process.stdout.write(data);
|
|
3320
|
+
},
|
|
3321
|
+
onStderr: (data) => {
|
|
3322
|
+
process.stderr.write(data);
|
|
3323
|
+
},
|
|
3324
|
+
onExit: (code, signal) => {
|
|
3325
|
+
if (code === 0) {
|
|
3326
|
+
resolve3();
|
|
3327
|
+
} else {
|
|
3328
|
+
reject(
|
|
3329
|
+
new Error(
|
|
3330
|
+
`session ${req.sessionId} exited with code=${code} signal=${signal}`
|
|
3331
|
+
)
|
|
3332
|
+
);
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
});
|
|
3336
|
+
});
|
|
3337
|
+
}
|
|
3338
|
+
};
|
|
3339
|
+
|
|
3340
|
+
// _apps/@onklave/agent-cli/src/services/daemon-token-observer.service.ts
|
|
3341
|
+
var WARNING_THRESHOLD_DAYS = 30;
|
|
3342
|
+
var DaemonTokenObserver = class {
|
|
3343
|
+
constructor(opts) {
|
|
3344
|
+
this.warningEmitted = false;
|
|
3345
|
+
this.expiredEmitted = false;
|
|
3346
|
+
this.machineId = opts.machineId;
|
|
3347
|
+
this.audit = opts.audit;
|
|
3348
|
+
this.expiresAt = opts.deviceTokenExpiresAt ? new Date(opts.deviceTokenExpiresAt) : null;
|
|
3349
|
+
this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
|
|
3350
|
+
}
|
|
3351
|
+
/**
|
|
3352
|
+
* Update the tracked expiry after a successful refresh. Resets the
|
|
3353
|
+
* one-shot emitted flags so a future warning / expiry can re-emit.
|
|
3354
|
+
*/
|
|
3355
|
+
updateExpiry(newExpiresAt) {
|
|
3356
|
+
this.expiresAt = new Date(newExpiresAt);
|
|
3357
|
+
this.warningEmitted = false;
|
|
3358
|
+
this.expiredEmitted = false;
|
|
3359
|
+
}
|
|
3360
|
+
/**
|
|
3361
|
+
* Snapshot the current expiry status. Idempotent — does not emit audit
|
|
3362
|
+
* events. Use {@link emitIfNeeded} for the side-effectful check.
|
|
3363
|
+
*/
|
|
3364
|
+
status() {
|
|
3365
|
+
if (!this.expiresAt || Number.isNaN(this.expiresAt.getTime())) {
|
|
3366
|
+
return {
|
|
3367
|
+
state: "unknown",
|
|
3368
|
+
message: "device token expiry not recorded \u2014 re-register to refresh"
|
|
3369
|
+
};
|
|
3370
|
+
}
|
|
3371
|
+
const msRemaining = this.expiresAt.getTime() - this.now().getTime();
|
|
3372
|
+
if (msRemaining <= 0) {
|
|
3373
|
+
return { state: "expired", expiresAt: this.expiresAt.toISOString() };
|
|
3374
|
+
}
|
|
3375
|
+
const daysRemaining = Math.floor(msRemaining / (24 * 60 * 60 * 1e3));
|
|
3376
|
+
if (daysRemaining <= WARNING_THRESHOLD_DAYS) {
|
|
3377
|
+
return {
|
|
3378
|
+
state: "warning",
|
|
3379
|
+
daysRemaining,
|
|
3380
|
+
expiresAt: this.expiresAt.toISOString()
|
|
3381
|
+
};
|
|
3382
|
+
}
|
|
3383
|
+
return {
|
|
3384
|
+
state: "ok",
|
|
3385
|
+
daysRemaining,
|
|
3386
|
+
expiresAt: this.expiresAt.toISOString()
|
|
3387
|
+
};
|
|
3388
|
+
}
|
|
3389
|
+
/**
|
|
3390
|
+
* Emit audit events for state changes (warning crossed, expiry hit).
|
|
3391
|
+
* Emits at most one warning and one expired event per observer
|
|
3392
|
+
* instance — repeated calls are silent until process restart.
|
|
3393
|
+
*/
|
|
3394
|
+
emitIfNeeded() {
|
|
3395
|
+
const status = this.status();
|
|
3396
|
+
if (status.state === "expired" && !this.expiredEmitted) {
|
|
3397
|
+
this.audit.record({
|
|
3398
|
+
sessionId: this.machineId,
|
|
3399
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3400
|
+
action: DAEMON_AUDIT_ACTIONS.AUTH_EXPIRED,
|
|
3401
|
+
details: { machineId: this.machineId, expiresAt: status.expiresAt },
|
|
3402
|
+
outcome: "failure"
|
|
3403
|
+
});
|
|
3404
|
+
this.expiredEmitted = true;
|
|
3405
|
+
} else if (status.state === "warning" && !this.warningEmitted) {
|
|
3406
|
+
this.audit.record({
|
|
3407
|
+
sessionId: this.machineId,
|
|
3408
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3409
|
+
action: DAEMON_AUDIT_ACTIONS.AUTH_WARNING,
|
|
3410
|
+
details: {
|
|
3411
|
+
machineId: this.machineId,
|
|
3412
|
+
daysRemaining: status.daysRemaining,
|
|
3413
|
+
expiresAt: status.expiresAt
|
|
3414
|
+
},
|
|
3415
|
+
outcome: "success"
|
|
3416
|
+
});
|
|
3417
|
+
this.warningEmitted = true;
|
|
3418
|
+
}
|
|
3419
|
+
return status;
|
|
3420
|
+
}
|
|
3421
|
+
/** True iff the token has expired right now. */
|
|
3422
|
+
isExpired() {
|
|
3423
|
+
return this.status().state === "expired";
|
|
3424
|
+
}
|
|
3425
|
+
};
|
|
3426
|
+
|
|
3427
|
+
// _apps/@onklave/agent-cli/src/services/daemon-token-refresher.service.ts
|
|
3428
|
+
var REFRESH_THRESHOLD_DAYS = 7;
|
|
3429
|
+
var DaemonTokenRefresher = class {
|
|
3430
|
+
constructor(opts) {
|
|
3431
|
+
this.opts = opts;
|
|
3432
|
+
this.inFlight = false;
|
|
3433
|
+
this.lastAttemptAt = null;
|
|
3434
|
+
}
|
|
3435
|
+
/**
|
|
3436
|
+
* Idempotent tick — call from the heartbeat / runtime publisher loop.
|
|
3437
|
+
* Does nothing if the token is healthy (>7 days remaining), if the
|
|
3438
|
+
* token's expiry is unknown, or if a refresh is already in flight.
|
|
3439
|
+
*/
|
|
3440
|
+
async maybeRefresh() {
|
|
3441
|
+
if (this.inFlight) return;
|
|
3442
|
+
const status = this.opts.observer.status();
|
|
3443
|
+
if (status.state === "unknown") return;
|
|
3444
|
+
if (status.state === "expired") return;
|
|
3445
|
+
if (status.daysRemaining > REFRESH_THRESHOLD_DAYS) return;
|
|
3446
|
+
this.inFlight = true;
|
|
3447
|
+
this.lastAttemptAt = /* @__PURE__ */ new Date();
|
|
3448
|
+
try {
|
|
3449
|
+
const currentToken = this.opts.getDeviceToken();
|
|
3450
|
+
const result = await this.opts.platformClient.refreshDeviceToken(
|
|
3451
|
+
currentToken
|
|
3452
|
+
);
|
|
3453
|
+
await this.opts.authService.saveDeviceToken(
|
|
3454
|
+
result.deviceToken,
|
|
3455
|
+
this.opts.machineId,
|
|
3456
|
+
result.deviceTokenExpiresAt
|
|
3457
|
+
);
|
|
3458
|
+
this.opts.observer.updateExpiry(result.deviceTokenExpiresAt);
|
|
3459
|
+
this.opts.onTokenRefreshed?.(
|
|
3460
|
+
result.deviceToken,
|
|
3461
|
+
result.deviceTokenExpiresAt
|
|
3462
|
+
);
|
|
3463
|
+
} catch (err) {
|
|
3464
|
+
console.warn(
|
|
3465
|
+
`[daemon] device-token refresh failed: ${err.message} (will retry)`
|
|
3466
|
+
);
|
|
3467
|
+
} finally {
|
|
3468
|
+
this.inFlight = false;
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
3471
|
+
/** Most recent refresh-attempt time, or null if never tried. */
|
|
3472
|
+
getLastAttemptAt() {
|
|
3473
|
+
return this.lastAttemptAt;
|
|
3474
|
+
}
|
|
3475
|
+
};
|
|
3476
|
+
|
|
3477
|
+
// _apps/@onklave/agent-cli/src/services/daemon-update-checker.service.ts
|
|
3478
|
+
var REGISTRY_URL = "https://registry.npmjs.org";
|
|
3479
|
+
var PACKAGE_NAME = "@onklave/agent-cli";
|
|
3480
|
+
var DaemonUpdateChecker = class {
|
|
3481
|
+
constructor(opts) {
|
|
3482
|
+
this.latestVersion = null;
|
|
3483
|
+
this.lastCheckedAt = null;
|
|
3484
|
+
this.emittedForVersion = null;
|
|
3485
|
+
this.currentVersion = opts.currentVersion;
|
|
3486
|
+
this.audit = opts.audit;
|
|
3487
|
+
this.registryFetcher = opts.registryFetcher ?? defaultRegistryFetcher;
|
|
3488
|
+
}
|
|
3489
|
+
/**
|
|
3490
|
+
* Hit the registry. On success, sets latestVersion and (if newer than
|
|
3491
|
+
* current) emits `daemon.update_available` — exactly once per version,
|
|
3492
|
+
* even across repeated checks. On failure, logs and returns; the next
|
|
3493
|
+
* tick can retry.
|
|
3494
|
+
*/
|
|
3495
|
+
async check() {
|
|
3496
|
+
try {
|
|
3497
|
+
const latest = await this.registryFetcher(PACKAGE_NAME);
|
|
3498
|
+
this.latestVersion = latest;
|
|
3499
|
+
this.lastCheckedAt = /* @__PURE__ */ new Date();
|
|
3500
|
+
if (isNewerVersion(latest, this.currentVersion) && this.emittedForVersion !== latest) {
|
|
3501
|
+
this.audit.record({
|
|
3502
|
+
sessionId: this.currentVersion,
|
|
3503
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3504
|
+
action: DAEMON_AUDIT_ACTIONS.UPDATE_AVAILABLE,
|
|
3505
|
+
details: {
|
|
3506
|
+
currentVersion: this.currentVersion,
|
|
3507
|
+
latestVersion: latest
|
|
3508
|
+
},
|
|
3509
|
+
outcome: "success"
|
|
3510
|
+
});
|
|
3511
|
+
this.emittedForVersion = latest;
|
|
3512
|
+
}
|
|
3513
|
+
} catch (err) {
|
|
3514
|
+
console.warn(
|
|
3515
|
+
`[daemon] update check failed: ${err.message} (will retry)`
|
|
3516
|
+
);
|
|
3517
|
+
}
|
|
3518
|
+
}
|
|
3519
|
+
info() {
|
|
3520
|
+
return {
|
|
3521
|
+
currentVersion: this.currentVersion,
|
|
3522
|
+
latestVersion: this.latestVersion,
|
|
3523
|
+
updateAvailable: !!this.latestVersion && isNewerVersion(this.latestVersion, this.currentVersion),
|
|
3524
|
+
checkedAt: this.lastCheckedAt?.toISOString() ?? null
|
|
3525
|
+
};
|
|
3526
|
+
}
|
|
3527
|
+
};
|
|
3528
|
+
async function defaultRegistryFetcher(packageName) {
|
|
3529
|
+
const url = `${REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`;
|
|
3530
|
+
const response = await fetch(url, {
|
|
3531
|
+
method: "GET",
|
|
3532
|
+
headers: { Accept: "application/json" },
|
|
3533
|
+
signal: AbortSignal.timeout(1e4)
|
|
3534
|
+
});
|
|
3535
|
+
if (!response.ok) {
|
|
3536
|
+
throw new Error(`registry ${response.status} ${response.statusText}`);
|
|
3537
|
+
}
|
|
3538
|
+
const body = await response.json();
|
|
3539
|
+
if (!body.version) throw new Error("registry response missing version");
|
|
3540
|
+
return body.version;
|
|
3541
|
+
}
|
|
3542
|
+
function isNewerVersion(a, b) {
|
|
3543
|
+
const pa = parseSemverNumeric(a);
|
|
3544
|
+
const pb = parseSemverNumeric(b);
|
|
3545
|
+
if (!pa || !pb) return false;
|
|
3546
|
+
for (let i = 0; i < 3; i += 1) {
|
|
3547
|
+
if (pa[i] > pb[i]) return true;
|
|
3548
|
+
if (pa[i] < pb[i]) return false;
|
|
3549
|
+
}
|
|
3550
|
+
return false;
|
|
3551
|
+
}
|
|
3552
|
+
function parseSemverNumeric(v) {
|
|
3553
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
3554
|
+
if (!match) return null;
|
|
3555
|
+
return [
|
|
3556
|
+
Number.parseInt(match[1], 10),
|
|
3557
|
+
Number.parseInt(match[2], 10),
|
|
3558
|
+
Number.parseInt(match[3], 10)
|
|
3559
|
+
];
|
|
3560
|
+
}
|
|
3561
|
+
|
|
3562
|
+
// _apps/@onklave/agent-cli/src/services/daemon-state.service.ts
|
|
3563
|
+
import * as fs5 from "fs";
|
|
3564
|
+
import * as path6 from "path";
|
|
3565
|
+
import * as os5 from "os";
|
|
3566
|
+
var VALID_TRANSITIONS = {
|
|
3567
|
+
installing: ["registered"],
|
|
3568
|
+
registered: ["starting"],
|
|
3569
|
+
starting: ["online", "stopping"],
|
|
3570
|
+
online: ["draining", "stopping"],
|
|
3571
|
+
draining: ["stopping"],
|
|
3572
|
+
stopping: ["stopped"],
|
|
3573
|
+
stopped: ["starting"]
|
|
3574
|
+
};
|
|
3575
|
+
function defaultStateFilePath() {
|
|
3576
|
+
return path6.join(os5.homedir(), ".config", "onklave", "daemon.state.json");
|
|
3577
|
+
}
|
|
3578
|
+
function defaultPidFilePath() {
|
|
3579
|
+
return path6.join(os5.homedir(), ".config", "onklave", "daemon.pid");
|
|
3580
|
+
}
|
|
3581
|
+
var DaemonStateError = class extends Error {
|
|
3582
|
+
constructor(message) {
|
|
3583
|
+
super(message);
|
|
3584
|
+
this.name = "DaemonStateError";
|
|
3585
|
+
}
|
|
3586
|
+
};
|
|
3587
|
+
var DaemonStateService = class {
|
|
3588
|
+
constructor(stateFile = defaultStateFilePath(), initial = "registered", machineId) {
|
|
3589
|
+
this.stateFile = stateFile;
|
|
3590
|
+
this.machineId = machineId;
|
|
3591
|
+
this.listeners = [];
|
|
3592
|
+
this.latestRuntime = null;
|
|
3593
|
+
this.current = initial;
|
|
3594
|
+
this.enteredAt = /* @__PURE__ */ new Date();
|
|
3595
|
+
}
|
|
3596
|
+
/**
|
|
3597
|
+
* Read the persisted state from disk, if any. Used by `daemon status`
|
|
3598
|
+
* when the daemon process is not running. Returns null on missing or
|
|
3599
|
+
* malformed file rather than throwing so the caller can decide.
|
|
3600
|
+
*/
|
|
3601
|
+
static readPersisted(stateFile = defaultStateFilePath()) {
|
|
3602
|
+
try {
|
|
3603
|
+
if (!fs5.existsSync(stateFile)) return null;
|
|
3604
|
+
const raw = fs5.readFileSync(stateFile, "utf8");
|
|
3605
|
+
const parsed = JSON.parse(raw);
|
|
3606
|
+
if (!parsed.state || !parsed.enteredAt) return null;
|
|
3607
|
+
return parsed;
|
|
3608
|
+
} catch {
|
|
3609
|
+
return null;
|
|
3610
|
+
}
|
|
3611
|
+
}
|
|
3612
|
+
getCurrent() {
|
|
3613
|
+
return this.current;
|
|
3614
|
+
}
|
|
3615
|
+
getEnteredAt() {
|
|
3616
|
+
return this.enteredAt;
|
|
3617
|
+
}
|
|
3618
|
+
/**
|
|
3619
|
+
* Subscribe to transitions. Listeners are awaited sequentially in
|
|
3620
|
+
* registration order. If a listener throws, the transition is treated
|
|
3621
|
+
* as failed — the state is rolled back and the error propagates so the
|
|
3622
|
+
* caller can decide. (Audit emission listeners that fail must therefore
|
|
3623
|
+
* fail the transition — that is the constitutional `audit-fails-action`
|
|
3624
|
+
* coupling described in spec §1.4.)
|
|
3625
|
+
*/
|
|
3626
|
+
onTransition(cb) {
|
|
3627
|
+
this.listeners.push(cb);
|
|
3628
|
+
}
|
|
3629
|
+
/**
|
|
3630
|
+
* Attempt a transition. Throws DaemonStateError on illegal transition
|
|
3631
|
+
* (current → next not in VALID_TRANSITIONS). Throws whatever a listener
|
|
3632
|
+
* throws if any listener fails; in that case state is rolled back to
|
|
3633
|
+
* `prev` and is not persisted.
|
|
3634
|
+
*/
|
|
3635
|
+
async transition(next, opts = {}) {
|
|
3636
|
+
const prev = this.current;
|
|
3637
|
+
const allowed = VALID_TRANSITIONS[prev];
|
|
3638
|
+
if (!allowed.includes(next)) {
|
|
3639
|
+
throw new DaemonStateError(`illegal daemon transition ${prev} \u2192 ${next}`);
|
|
3640
|
+
}
|
|
3641
|
+
this.current = next;
|
|
3642
|
+
this.enteredAt = /* @__PURE__ */ new Date();
|
|
3643
|
+
try {
|
|
3644
|
+
for (const listener of this.listeners) {
|
|
3645
|
+
await listener(next, prev, opts.reason);
|
|
3646
|
+
}
|
|
3647
|
+
this.persist(opts.reason);
|
|
3648
|
+
} catch (err) {
|
|
3649
|
+
this.current = prev;
|
|
3650
|
+
throw err;
|
|
3651
|
+
}
|
|
3652
|
+
}
|
|
3653
|
+
persist(reason) {
|
|
3654
|
+
const dir = path6.dirname(this.stateFile);
|
|
3655
|
+
fs5.mkdirSync(dir, { recursive: true });
|
|
3656
|
+
const payload = {
|
|
3657
|
+
state: this.current,
|
|
3658
|
+
enteredAt: this.enteredAt.toISOString(),
|
|
3659
|
+
pid: process.pid,
|
|
3660
|
+
...this.machineId ? { machineId: this.machineId } : {},
|
|
3661
|
+
...reason ? { reason } : {},
|
|
3662
|
+
...this.latestRuntime ? { runtime: this.latestRuntime } : {}
|
|
3663
|
+
};
|
|
3664
|
+
const tmp = `${this.stateFile}.tmp`;
|
|
3665
|
+
fs5.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
3666
|
+
fs5.renameSync(tmp, this.stateFile);
|
|
3667
|
+
}
|
|
3668
|
+
/**
|
|
3669
|
+
* Publish the latest runtime snapshot. Persists to the state file
|
|
3670
|
+
* so `daemon status` from another process can read it. Idempotent —
|
|
3671
|
+
* no transition is fired.
|
|
3672
|
+
*/
|
|
3673
|
+
publishRuntime(snapshot) {
|
|
3674
|
+
this.latestRuntime = snapshot;
|
|
3675
|
+
this.persist();
|
|
3676
|
+
}
|
|
3677
|
+
/**
|
|
3678
|
+
* Remove the persisted state file. Called on terminal `stopped` once
|
|
3679
|
+
* the daemon process is exiting cleanly so `daemon status` reports
|
|
3680
|
+
* "registered — daemon not running" instead of stale "stopped".
|
|
3681
|
+
*/
|
|
3682
|
+
clearPersisted() {
|
|
3683
|
+
try {
|
|
3684
|
+
fs5.unlinkSync(this.stateFile);
|
|
3685
|
+
} catch {
|
|
3686
|
+
}
|
|
3687
|
+
}
|
|
3688
|
+
};
|
|
3689
|
+
|
|
3690
|
+
// _apps/@onklave/agent-cli/src/services/unit-file-emitters.ts
|
|
3691
|
+
var SYSTEMD_UNIT = `[Unit]
|
|
3692
|
+
Description=Onklave Agent CLI Daemon
|
|
3693
|
+
Documentation=https://docs.onklave.app/cli/daemon
|
|
3694
|
+
After=network-online.target
|
|
3695
|
+
Wants=network-online.target
|
|
3696
|
+
|
|
3697
|
+
[Service]
|
|
3698
|
+
Type=simple
|
|
3699
|
+
User=onklave
|
|
3700
|
+
Group=onklave
|
|
3701
|
+
Environment="NODE_ENV=production"
|
|
3702
|
+
Environment="ONKLAVE_CONFIG_DIR=/var/lib/onklave"
|
|
3703
|
+
Environment="ONKLAVE_LOG_DIR=/var/log/onklave"
|
|
3704
|
+
ExecStart=/usr/local/bin/onklave daemon
|
|
3705
|
+
ExecStop=/usr/local/bin/onklave daemon drain --timeout 1800
|
|
3706
|
+
Restart=on-failure
|
|
3707
|
+
RestartSec=10s
|
|
3708
|
+
StartLimitIntervalSec=300
|
|
3709
|
+
StartLimitBurst=5
|
|
3710
|
+
KillMode=mixed
|
|
3711
|
+
KillSignal=SIGTERM
|
|
3712
|
+
TimeoutStopSec=1830s
|
|
3713
|
+
|
|
3714
|
+
# Hardening
|
|
3715
|
+
NoNewPrivileges=true
|
|
3716
|
+
ProtectSystem=strict
|
|
3717
|
+
ProtectHome=true
|
|
3718
|
+
PrivateTmp=true
|
|
3719
|
+
ReadWritePaths=/var/lib/onklave /var/log/onklave
|
|
3720
|
+
|
|
3721
|
+
[Install]
|
|
3722
|
+
WantedBy=multi-user.target
|
|
3723
|
+
`;
|
|
3724
|
+
var LAUNCHD_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
|
|
3725
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3726
|
+
<plist version="1.0">
|
|
3727
|
+
<dict>
|
|
3728
|
+
<key>Label</key>
|
|
3729
|
+
<string>app.onklave.daemon</string>
|
|
3730
|
+
<key>ProgramArguments</key>
|
|
3731
|
+
<array>
|
|
3732
|
+
<string>/usr/local/bin/onklave</string>
|
|
3733
|
+
<string>daemon</string>
|
|
3734
|
+
</array>
|
|
3735
|
+
<key>RunAtLoad</key>
|
|
3736
|
+
<true/>
|
|
3737
|
+
<key>KeepAlive</key>
|
|
3738
|
+
<dict>
|
|
3739
|
+
<key>SuccessfulExit</key>
|
|
3740
|
+
<false/>
|
|
3741
|
+
<key>Crashed</key>
|
|
3742
|
+
<true/>
|
|
3743
|
+
</dict>
|
|
3744
|
+
<key>ThrottleInterval</key>
|
|
3745
|
+
<integer>10</integer>
|
|
3746
|
+
<key>EnvironmentVariables</key>
|
|
3747
|
+
<dict>
|
|
3748
|
+
<key>NODE_ENV</key>
|
|
3749
|
+
<string>production</string>
|
|
3750
|
+
<key>ONKLAVE_CONFIG_DIR</key>
|
|
3751
|
+
<string>/Users/Shared/onklave</string>
|
|
3752
|
+
</dict>
|
|
3753
|
+
<key>StandardOutPath</key>
|
|
3754
|
+
<string>/Users/Shared/onklave/logs/daemon.out.log</string>
|
|
3755
|
+
<key>StandardErrorPath</key>
|
|
3756
|
+
<string>/Users/Shared/onklave/logs/daemon.err.log</string>
|
|
3757
|
+
<key>ProcessType</key>
|
|
3758
|
+
<string>Background</string>
|
|
3759
|
+
</dict>
|
|
3760
|
+
</plist>
|
|
3761
|
+
`;
|
|
3762
|
+
var NSSM_SCRIPT = `@echo off
|
|
3763
|
+
REM Onklave Agent CLI \u2014 NSSM service install script
|
|
3764
|
+
REM Run this as Administrator.
|
|
3765
|
+
|
|
3766
|
+
set NSSM=nssm.exe
|
|
3767
|
+
set SERVICE=OnklaveDaemon
|
|
3768
|
+
set NODE_EXE=%ProgramFiles%\\nodejs\\node.exe
|
|
3769
|
+
set ONKLAVE_CLI=%AppData%\\npm\\node_modules\\@onklave\\agent-cli\\dist\\bin\\onklave.js
|
|
3770
|
+
|
|
3771
|
+
%NSSM% install %SERVICE% "%NODE_EXE%" "%ONKLAVE_CLI%" daemon
|
|
3772
|
+
%NSSM% set %SERVICE% DisplayName "Onklave Agent CLI Daemon"
|
|
3773
|
+
%NSSM% set %SERVICE% Description "Long-running agent worker for the Onklave platform"
|
|
3774
|
+
%NSSM% set %SERVICE% Start SERVICE_AUTO_START
|
|
3775
|
+
%NSSM% set %SERVICE% AppEnvironmentExtra NODE_ENV=production ONKLAVE_CONFIG_DIR=%ProgramData%\\Onklave
|
|
3776
|
+
%NSSM% set %SERVICE% AppStdout %ProgramData%\\Onklave\\logs\\daemon.out.log
|
|
3777
|
+
%NSSM% set %SERVICE% AppStderr %ProgramData%\\Onklave\\logs\\daemon.err.log
|
|
3778
|
+
%NSSM% set %SERVICE% AppRotateFiles 1
|
|
3779
|
+
%NSSM% set %SERVICE% AppRotateOnline 1
|
|
3780
|
+
%NSSM% set %SERVICE% AppRotateBytes 10485760
|
|
3781
|
+
%NSSM% set %SERVICE% AppExit Default Restart
|
|
3782
|
+
%NSSM% set %SERVICE% AppRestartDelay 10000
|
|
3783
|
+
|
|
3784
|
+
%NSSM% start %SERVICE%
|
|
3785
|
+
`;
|
|
3786
|
+
function emitUnitFile(flavour) {
|
|
3787
|
+
switch (flavour) {
|
|
3788
|
+
case "systemd":
|
|
3789
|
+
return SYSTEMD_UNIT;
|
|
3790
|
+
case "launchd":
|
|
3791
|
+
return LAUNCHD_PLIST;
|
|
3792
|
+
case "nssm":
|
|
3793
|
+
return NSSM_SCRIPT;
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
|
|
3797
|
+
// _apps/@onklave/agent-cli/src/commands/daemon.command.ts
|
|
3798
|
+
var UNIT_FLAGS = {
|
|
3799
|
+
"--emit-systemd-unit": "systemd",
|
|
3800
|
+
"--emit-launchd-plist": "launchd",
|
|
3801
|
+
"--emit-nssm-script": "nssm"
|
|
3802
|
+
};
|
|
3803
|
+
async function daemonCommand(args) {
|
|
3804
|
+
for (const arg of args) {
|
|
3805
|
+
const flavour = UNIT_FLAGS[arg];
|
|
3806
|
+
if (flavour) {
|
|
3807
|
+
process.stdout.write(emitUnitFile(flavour));
|
|
3808
|
+
return;
|
|
3809
|
+
}
|
|
3810
|
+
}
|
|
3811
|
+
const [sub] = args;
|
|
3812
|
+
if (sub === "status") return daemonStatus();
|
|
3813
|
+
if (sub === "stop" || sub === "drain") return daemonStop();
|
|
3814
|
+
if (sub === "update") return daemonUpdate();
|
|
3815
|
+
if (sub && !sub.startsWith("--")) {
|
|
3816
|
+
console.error(`Unknown subcommand: ${sub}`);
|
|
3817
|
+
console.error(
|
|
3818
|
+
"Usage: onklave daemon [status|stop|drain|update] | --emit-systemd-unit | --emit-launchd-plist | --emit-nssm-script"
|
|
3819
|
+
);
|
|
3820
|
+
process.exitCode = 1;
|
|
3821
|
+
return;
|
|
3822
|
+
}
|
|
3823
|
+
return daemonStart();
|
|
3824
|
+
}
|
|
3825
|
+
async function daemonStart() {
|
|
3826
|
+
const authService = new AuthService();
|
|
3827
|
+
const creds = await authService.getCredentials();
|
|
3828
|
+
if (!creds?.deviceToken || !creds?.machineId) {
|
|
3829
|
+
console.error(
|
|
3830
|
+
"Error: machine is not registered. Run: onklave register --token <bootstrap>"
|
|
3831
|
+
);
|
|
3832
|
+
process.exitCode = 1;
|
|
3833
|
+
return;
|
|
3834
|
+
}
|
|
3835
|
+
const pidFile = defaultPidFilePath();
|
|
3836
|
+
if (isPidAlive(pidFile)) {
|
|
3837
|
+
console.error(`Daemon already running (pid ${readPid(pidFile)}).`);
|
|
3838
|
+
process.exitCode = 1;
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3841
|
+
const stateService = new DaemonStateService(
|
|
3842
|
+
defaultStateFilePath(),
|
|
3843
|
+
"registered",
|
|
3844
|
+
creds.machineId
|
|
3845
|
+
);
|
|
3846
|
+
const platformUrl = await authService.getPlatformUrl();
|
|
3847
|
+
const comms = new DaemonCommsService({
|
|
3848
|
+
platformUrl,
|
|
3849
|
+
token: creds.token,
|
|
3850
|
+
machineId: creds.machineId,
|
|
3851
|
+
deviceToken: creds.deviceToken,
|
|
3852
|
+
orgId: creds.orgId
|
|
3853
|
+
});
|
|
3854
|
+
const auditStreamer = new AuditStreamer(comms.inner());
|
|
3855
|
+
stateService.onTransition(async (next, prev, reason) => {
|
|
3856
|
+
const action = transitionToAction(next);
|
|
3857
|
+
if (!action) return;
|
|
3858
|
+
const event = {
|
|
3859
|
+
sessionId: creds.machineId,
|
|
3860
|
+
type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
|
|
3861
|
+
action,
|
|
3862
|
+
details: { from: prev, to: next, ...reason ? { reason } : {} },
|
|
3863
|
+
outcome: next === "stopped" && reason ? "failure" : "success"
|
|
3864
|
+
};
|
|
3865
|
+
auditStreamer.record(event);
|
|
3866
|
+
});
|
|
3867
|
+
const spawner = new DaemonSpawner({ platformUrl });
|
|
3868
|
+
const platformClient = new PlatformClient(platformUrl, creds.token);
|
|
3869
|
+
const resourceSampler = new DaemonResourceSampler();
|
|
3870
|
+
const claimHandler = new DaemonClaimHandler({
|
|
3871
|
+
machineId: creds.machineId,
|
|
3872
|
+
audit: auditStreamer,
|
|
3873
|
+
spawnRunner: (req) => spawner.runSpawnRequest(req),
|
|
3874
|
+
platformClient,
|
|
3875
|
+
deviceToken: creds.deviceToken,
|
|
3876
|
+
resourceSampler,
|
|
3877
|
+
maxCpuPercent: 80
|
|
3878
|
+
// spec §8.1 default
|
|
3879
|
+
});
|
|
3880
|
+
const tokenObserver = new DaemonTokenObserver({
|
|
3881
|
+
machineId: creds.machineId,
|
|
3882
|
+
audit: auditStreamer,
|
|
3883
|
+
deviceTokenExpiresAt: creds.deviceTokenExpiresAt
|
|
3884
|
+
});
|
|
3885
|
+
const updateChecker = new DaemonUpdateChecker({
|
|
3886
|
+
currentVersion: readPackageVersion() ?? "0.0.0",
|
|
3887
|
+
audit: auditStreamer
|
|
3888
|
+
});
|
|
3889
|
+
let currentDeviceToken = creds.deviceToken;
|
|
3890
|
+
const tokenRefresher = new DaemonTokenRefresher({
|
|
3891
|
+
machineId: creds.machineId,
|
|
3892
|
+
observer: tokenObserver,
|
|
3893
|
+
platformClient,
|
|
3894
|
+
authService,
|
|
3895
|
+
getDeviceToken: () => currentDeviceToken,
|
|
3896
|
+
onTokenRefreshed: (newToken) => {
|
|
3897
|
+
currentDeviceToken = newToken;
|
|
3898
|
+
}
|
|
3899
|
+
});
|
|
3900
|
+
const initialTokenStatus = tokenObserver.emitIfNeeded();
|
|
3901
|
+
if (initialTokenStatus.state === "expired") {
|
|
3902
|
+
console.error(
|
|
3903
|
+
`Device token expired (${initialTokenStatus.expiresAt}). Re-register: onklave register --token <bootstrap>`
|
|
3904
|
+
);
|
|
3905
|
+
removePid(pidFile);
|
|
3906
|
+
stateService.clearPersisted();
|
|
3907
|
+
process.exit(1);
|
|
3908
|
+
}
|
|
3909
|
+
if (initialTokenStatus.state === "warning") {
|
|
3910
|
+
console.warn(
|
|
3911
|
+
`\u26A0 Device token expires in ${initialTokenStatus.daysRemaining} days (${initialTokenStatus.expiresAt}).`
|
|
3912
|
+
);
|
|
3913
|
+
}
|
|
3914
|
+
let shuttingDown = false;
|
|
3915
|
+
let sigintCount = 0;
|
|
3916
|
+
let sigintTimer = null;
|
|
3917
|
+
let runtimePublisher = null;
|
|
3918
|
+
let updateCheckTimer = null;
|
|
3919
|
+
const heartbeat = new HeartbeatService({
|
|
3920
|
+
platformUrl,
|
|
3921
|
+
deviceToken: creds.deviceToken,
|
|
3922
|
+
machineId: creds.machineId,
|
|
3923
|
+
getActiveSessionCount: () => claimHandler.getActiveSessionCount()
|
|
3924
|
+
});
|
|
3925
|
+
const drainAndExit = async (reason) => {
|
|
3926
|
+
if (shuttingDown) return;
|
|
3927
|
+
shuttingDown = true;
|
|
3928
|
+
heartbeat.stop();
|
|
3929
|
+
resourceSampler.stop();
|
|
3930
|
+
if (runtimePublisher) clearInterval(runtimePublisher);
|
|
3931
|
+
if (updateCheckTimer) clearInterval(updateCheckTimer);
|
|
3932
|
+
try {
|
|
3933
|
+
if (stateService.getCurrent() === "online") {
|
|
3934
|
+
await stateService.transition("draining", { reason });
|
|
3935
|
+
}
|
|
3936
|
+
while (claimHandler.getActiveSessionCount() > 0) {
|
|
3937
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
3938
|
+
}
|
|
3939
|
+
await stateService.transition("stopping", { reason });
|
|
3940
|
+
auditStreamer.flush();
|
|
3941
|
+
comms.disconnect();
|
|
3942
|
+
await stateService.transition("stopped");
|
|
3943
|
+
} finally {
|
|
3944
|
+
removePid(pidFile);
|
|
3945
|
+
stateService.clearPersisted();
|
|
3946
|
+
process.exit(0);
|
|
3947
|
+
}
|
|
3948
|
+
};
|
|
3949
|
+
process.on("SIGTERM", () => {
|
|
3950
|
+
void drainAndExit("SIGTERM");
|
|
3951
|
+
});
|
|
3952
|
+
process.on("SIGINT", () => {
|
|
3953
|
+
sigintCount += 1;
|
|
3954
|
+
if (sigintCount >= 2) {
|
|
3955
|
+
console.error("\nForce-stop requested. Exiting immediately.");
|
|
3956
|
+
removePid(pidFile);
|
|
3957
|
+
process.exit(1);
|
|
3958
|
+
}
|
|
3959
|
+
if (!sigintTimer) {
|
|
3960
|
+
sigintTimer = setTimeout(() => {
|
|
3961
|
+
sigintCount = 0;
|
|
3962
|
+
sigintTimer = null;
|
|
3963
|
+
}, 5e3);
|
|
3964
|
+
sigintTimer.unref?.();
|
|
3965
|
+
}
|
|
3966
|
+
console.log(
|
|
3967
|
+
"\nSIGINT received. Draining (Ctrl-C again within 5s to force stop)\u2026"
|
|
3968
|
+
);
|
|
3969
|
+
void drainAndExit("SIGINT");
|
|
3970
|
+
});
|
|
3971
|
+
await stateService.transition("starting");
|
|
3972
|
+
writePid(pidFile);
|
|
3973
|
+
console.log("Connecting to Onklave comms service...");
|
|
3974
|
+
try {
|
|
3975
|
+
await comms.connect();
|
|
3976
|
+
} catch (err) {
|
|
3977
|
+
console.error(
|
|
3978
|
+
`Comms connect aborted: ${err.message}. Shutting down.`
|
|
3979
|
+
);
|
|
3980
|
+
removePid(pidFile);
|
|
3981
|
+
stateService.clearPersisted();
|
|
3982
|
+
process.exit(1);
|
|
3983
|
+
}
|
|
3984
|
+
console.log("Connected.");
|
|
3985
|
+
claimHandler.attachToSocket(comms.inner());
|
|
3986
|
+
await stateService.transition("online");
|
|
3987
|
+
heartbeat.start();
|
|
3988
|
+
resourceSampler.start();
|
|
3989
|
+
const publishSnapshot = () => {
|
|
3990
|
+
void tokenRefresher.maybeRefresh();
|
|
3991
|
+
const tokenStatus = tokenObserver.emitIfNeeded();
|
|
3992
|
+
const disconnectedMs = comms.disconnectedForMs();
|
|
3993
|
+
const resourceSnap = resourceSampler.snapshot();
|
|
3994
|
+
stateService.publishRuntime({
|
|
3995
|
+
snapshotAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3996
|
+
activeSessions: claimHandler.getActiveSessionCount(),
|
|
3997
|
+
maxConcurrentSessions: claimHandler.getMaxConcurrentSessions(),
|
|
3998
|
+
socketConnected: comms.isConnected(),
|
|
3999
|
+
...disconnectedMs != null ? { socketDisconnectedForMs: disconnectedMs } : {},
|
|
4000
|
+
...resourceSnap.sampleCount > 0 ? {
|
|
4001
|
+
cpuPercent: resourceSnap.cpuPercent,
|
|
4002
|
+
memoryRssBytes: resourceSnap.memoryRssBytes
|
|
4003
|
+
} : {},
|
|
4004
|
+
updateInfo: updateChecker.info(),
|
|
4005
|
+
tokenStatus: tokenStatus.state === "unknown" ? { state: "unknown" } : tokenStatus.state === "expired" ? { state: "expired", expiresAt: tokenStatus.expiresAt } : {
|
|
4006
|
+
state: tokenStatus.state,
|
|
4007
|
+
daysRemaining: tokenStatus.daysRemaining,
|
|
4008
|
+
expiresAt: tokenStatus.expiresAt
|
|
4009
|
+
}
|
|
4010
|
+
});
|
|
4011
|
+
};
|
|
4012
|
+
publishSnapshot();
|
|
4013
|
+
runtimePublisher = setInterval(publishSnapshot, 3e4);
|
|
4014
|
+
runtimePublisher.unref?.();
|
|
4015
|
+
void updateChecker.check();
|
|
4016
|
+
updateCheckTimer = setInterval(() => {
|
|
4017
|
+
void updateChecker.check();
|
|
4018
|
+
}, 60 * 60 * 1e3);
|
|
4019
|
+
updateCheckTimer.unref?.();
|
|
4020
|
+
console.log(
|
|
4021
|
+
`Daemon online. machineId=${creds.machineId} pid=${process.pid}. Press Ctrl-C to drain.`
|
|
4022
|
+
);
|
|
4023
|
+
await new Promise(() => void 0);
|
|
4024
|
+
}
|
|
4025
|
+
async function daemonStatus() {
|
|
4026
|
+
const persisted = DaemonStateService.readPersisted();
|
|
4027
|
+
if (!persisted) {
|
|
4028
|
+
console.log("registered \u2014 daemon not running");
|
|
4029
|
+
return;
|
|
4030
|
+
}
|
|
4031
|
+
const alive = persisted.pid ? isProcessAlive(persisted.pid) : false;
|
|
4032
|
+
if (!alive) {
|
|
4033
|
+
console.log(
|
|
4034
|
+
`${persisted.state} \u2014 daemon process not running (last entered ${persisted.enteredAt})`
|
|
4035
|
+
);
|
|
4036
|
+
return;
|
|
4037
|
+
}
|
|
4038
|
+
console.log(`State: ${persisted.state}`);
|
|
4039
|
+
if (persisted.machineId)
|
|
4040
|
+
console.log(`Machine ID: ${persisted.machineId}`);
|
|
4041
|
+
if (persisted.pid) console.log(`PID: ${persisted.pid}`);
|
|
4042
|
+
console.log(`Entered state: ${persisted.enteredAt}`);
|
|
4043
|
+
if (persisted.reason) console.log(`Reason: ${persisted.reason}`);
|
|
4044
|
+
if (persisted.runtime) {
|
|
4045
|
+
const rt = persisted.runtime;
|
|
4046
|
+
console.log(
|
|
4047
|
+
`Active sessions: ${rt.activeSessions} / ${rt.maxConcurrentSessions}`
|
|
4048
|
+
);
|
|
4049
|
+
const socketStatus = rt.socketConnected ? "connected" : rt.socketDisconnectedForMs != null ? `disconnected ${Math.round(rt.socketDisconnectedForMs / 1e3)}s` : "disconnected";
|
|
4050
|
+
console.log(`Comms socket: ${socketStatus}`);
|
|
4051
|
+
if (rt.cpuPercent != null) {
|
|
4052
|
+
console.log(`CPU (60s avg): ${rt.cpuPercent.toFixed(1)}%`);
|
|
4053
|
+
}
|
|
4054
|
+
if (rt.memoryRssBytes != null) {
|
|
4055
|
+
const mb = rt.memoryRssBytes / (1024 * 1024);
|
|
4056
|
+
console.log(`Memory (RSS): ${mb.toFixed(1)} MB`);
|
|
4057
|
+
}
|
|
4058
|
+
if (rt.updateInfo) {
|
|
4059
|
+
const u = rt.updateInfo;
|
|
4060
|
+
const versionLine = u.updateAvailable ? `${u.currentVersion} \u2192 ${u.latestVersion} available (run: onklave daemon update)` : `${u.currentVersion}${u.latestVersion ? " (latest)" : ""}`;
|
|
4061
|
+
console.log(`Daemon version: ${versionLine}`);
|
|
4062
|
+
}
|
|
4063
|
+
if (rt.tokenStatus) {
|
|
4064
|
+
if (rt.tokenStatus.state === "expired") {
|
|
4065
|
+
console.log(`Device token: EXPIRED (${rt.tokenStatus.expiresAt})`);
|
|
4066
|
+
} else if (rt.tokenStatus.state === "warning") {
|
|
4067
|
+
console.log(
|
|
4068
|
+
`Device token: \u26A0 ${rt.tokenStatus.daysRemaining} days remaining (${rt.tokenStatus.expiresAt})`
|
|
4069
|
+
);
|
|
4070
|
+
} else if (rt.tokenStatus.state === "ok") {
|
|
4071
|
+
console.log(
|
|
4072
|
+
`Device token: ${rt.tokenStatus.daysRemaining} days remaining`
|
|
4073
|
+
);
|
|
4074
|
+
} else {
|
|
4075
|
+
console.log("Device token: expiry unknown \u2014 re-register to refresh");
|
|
4076
|
+
}
|
|
4077
|
+
}
|
|
4078
|
+
console.log(`Snapshot age: ${snapshotAge(rt.snapshotAt)}`);
|
|
4079
|
+
}
|
|
4080
|
+
}
|
|
4081
|
+
function snapshotAge(iso) {
|
|
4082
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
4083
|
+
if (ms < 0) return "just now";
|
|
4084
|
+
if (ms < 6e4) return `${Math.round(ms / 1e3)}s ago`;
|
|
4085
|
+
if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
|
|
4086
|
+
return `${Math.round(ms / 36e5)}h ago`;
|
|
4087
|
+
}
|
|
4088
|
+
async function daemonUpdate() {
|
|
4089
|
+
console.log("To update the Onklave agent CLI, run as the install owner:");
|
|
4090
|
+
console.log("");
|
|
4091
|
+
console.log(" npm install -g @onklave/agent-cli@latest");
|
|
4092
|
+
console.log("");
|
|
4093
|
+
console.log(
|
|
4094
|
+
"Then restart the supervisor: `systemctl restart onklave-daemon` (Linux),"
|
|
4095
|
+
);
|
|
4096
|
+
console.log("`launchctl kickstart -k system/app.onklave.daemon` (macOS), or");
|
|
4097
|
+
console.log("`nssm restart OnklaveDaemon` (Windows).");
|
|
4098
|
+
console.log("");
|
|
4099
|
+
console.log(
|
|
4100
|
+
"Self-replace via npm provenance verification (spec \xA76.3) is deferred \u2014 see V6-CLI-002 Phase 7 in the kanban."
|
|
4101
|
+
);
|
|
4102
|
+
}
|
|
4103
|
+
async function daemonStop() {
|
|
4104
|
+
const pidFile = defaultPidFilePath();
|
|
4105
|
+
const pid = readPid(pidFile);
|
|
4106
|
+
if (!pid || !isProcessAlive(pid)) {
|
|
4107
|
+
console.log("No daemon running.");
|
|
4108
|
+
removePid(pidFile);
|
|
4109
|
+
return;
|
|
4110
|
+
}
|
|
4111
|
+
try {
|
|
4112
|
+
process.kill(pid, "SIGTERM");
|
|
4113
|
+
console.log(`Sent SIGTERM to daemon (pid ${pid}).`);
|
|
4114
|
+
} catch (err) {
|
|
4115
|
+
console.error(`Failed to signal daemon: ${err.message}`);
|
|
4116
|
+
process.exitCode = 1;
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
function transitionToAction(next) {
|
|
4120
|
+
switch (next) {
|
|
4121
|
+
case "starting":
|
|
4122
|
+
return DAEMON_AUDIT_ACTIONS.STARTED;
|
|
4123
|
+
case "online":
|
|
4124
|
+
return DAEMON_AUDIT_ACTIONS.ONLINE;
|
|
4125
|
+
case "draining":
|
|
4126
|
+
return DAEMON_AUDIT_ACTIONS.DRAINING;
|
|
4127
|
+
case "stopped":
|
|
4128
|
+
return DAEMON_AUDIT_ACTIONS.STOPPED;
|
|
4129
|
+
default:
|
|
4130
|
+
return null;
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
function readPid(pidFile) {
|
|
4134
|
+
try {
|
|
4135
|
+
const raw = fs6.readFileSync(pidFile, "utf8").trim();
|
|
4136
|
+
const parsed = Number.parseInt(raw, 10);
|
|
4137
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
4138
|
+
} catch {
|
|
4139
|
+
return null;
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
function writePid(pidFile) {
|
|
4143
|
+
const dir = pidFile.replace(/\/[^/]+$/, "");
|
|
4144
|
+
fs6.mkdirSync(dir, { recursive: true });
|
|
4145
|
+
fs6.writeFileSync(pidFile, String(process.pid), { mode: 384 });
|
|
4146
|
+
}
|
|
4147
|
+
function removePid(pidFile) {
|
|
4148
|
+
try {
|
|
4149
|
+
fs6.unlinkSync(pidFile);
|
|
4150
|
+
} catch {
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
function isPidAlive(pidFile) {
|
|
4154
|
+
const pid = readPid(pidFile);
|
|
4155
|
+
if (pid == null) return false;
|
|
4156
|
+
return isProcessAlive(pid);
|
|
4157
|
+
}
|
|
4158
|
+
function readPackageVersion() {
|
|
4159
|
+
try {
|
|
4160
|
+
const candidates = [
|
|
4161
|
+
// dist layout (bin/onklave.js → ../package.json)
|
|
4162
|
+
`${__dirname}/../../package.json`,
|
|
4163
|
+
// src layout during dev
|
|
4164
|
+
`${__dirname}/../../../package.json`
|
|
4165
|
+
];
|
|
4166
|
+
for (const p of candidates) {
|
|
4167
|
+
if (fs6.existsSync(p)) {
|
|
4168
|
+
const pkg = JSON.parse(fs6.readFileSync(p, "utf8"));
|
|
4169
|
+
if (pkg.name === "@onklave/agent-cli" && pkg.version)
|
|
4170
|
+
return pkg.version;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
} catch {
|
|
4174
|
+
}
|
|
4175
|
+
return null;
|
|
4176
|
+
}
|
|
4177
|
+
function isProcessAlive(pid) {
|
|
4178
|
+
try {
|
|
4179
|
+
process.kill(pid, 0);
|
|
4180
|
+
return true;
|
|
4181
|
+
} catch (err) {
|
|
4182
|
+
if (err.code === "EPERM") {
|
|
4183
|
+
return true;
|
|
4184
|
+
}
|
|
4185
|
+
return false;
|
|
4186
|
+
}
|
|
4187
|
+
}
|
|
4188
|
+
|
|
2849
4189
|
// _apps/@onklave/agent-cli/src/commands/index.ts
|
|
2850
4190
|
var COMMANDS = {
|
|
2851
4191
|
login: loginCommand,
|
|
@@ -2864,7 +4204,8 @@ var COMMANDS = {
|
|
|
2864
4204
|
init: (_args) => initCommand(),
|
|
2865
4205
|
config: configCommand,
|
|
2866
4206
|
register: registerCommand,
|
|
2867
|
-
logs: logsCommand
|
|
4207
|
+
logs: logsCommand,
|
|
4208
|
+
daemon: daemonCommand
|
|
2868
4209
|
};
|
|
2869
4210
|
|
|
2870
4211
|
// _apps/@onklave/agent-cli/src/main.ts
|