@cotal-ai/manager 0.51.0 → 0.52.1
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/manager-service-contract.d.ts +11 -2
- package/dist/manager-service-contract.d.ts.map +1 -1
- package/dist/manager-service-contract.js +70 -11
- package/dist/manager-service-contract.js.map +1 -1
- package/dist/manager.d.ts +33 -4
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +105 -20
- package/dist/manager.js.map +1 -1
- package/dist/run-hosting.d.ts +2 -2
- package/dist/run-hosting.d.ts.map +1 -1
- package/dist/run-hosting.js +28 -7
- package/dist/run-hosting.js.map +1 -1
- package/package.json +10 -10
package/dist/manager.js
CHANGED
|
@@ -2,8 +2,8 @@ import { execFile } from "node:child_process";
|
|
|
2
2
|
import { createHash, randomUUID, randomBytes } from "node:crypto";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { credsAuthenticator } from "@nats-io/transport-node";
|
|
5
|
-
import { existsSync, lstatSync, readFileSync, rmSync } from "node:fs";
|
|
6
|
-
import { join, dirname, resolve } from "node:path";
|
|
5
|
+
import { existsSync, lstatSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
|
|
6
|
+
import { isAbsolute, join, dirname, resolve } from "node:path";
|
|
7
7
|
import { CotalEndpoint, DEFAULT_SERVER, DEV_OWNER, MANAGER_LEASE_RENEW_MS, STANDING_RENEWABLE_TTL_SEC, divergentSecretStoreNotice, parseSecretStoreIdentity, sameSecretStoreIdentity, agentFilePath, clearSpaceHistory, dialerFor, connectorServers, spawnEnvAllow, deprovisionAgent, firstFreeName, spawnNameError, idFromCreds, inspectCredHealth, composeWirePersona, loadAgentFile, listPersonaCatalog, personaCatalogDescription, personaCatalogReadable, loadCotalConfig, mintCreds, mintGeneration, mintAcceptedToken, openIssuedStore, acceptedBucket, issuedBucket, mintLifecycleUid, managedRetirementOpId, mkSecretDir, newIdentity, actionContext, parsePrincipalKey, parseShareSelection, principalKey, probeConnect, provisionAgent, provisionAgentDurables, registry, resolveAuthProvider, saveAgentFile, subjectMatches, AUTH_ENDPOINT, EP_CMD_RETIRE_LIFECYCLE, epRequestSubject, epCallerReplyFilter, parseEpSubject, controlServiceSubject, eventChannelPrincipal, } from "@cotal-ai/core";
|
|
8
8
|
import { agentAuthState, agentCredsDir, agentLifecycleSecretFilePaths, agentSecretFilePaths, agentSecretKeyForFile, authDir, connectorInstallHint, DEFAULT_CONNECTOR, defaultAgentType, DELIVERY_CREDS_KIND, extensionConnectors, findCotalRoot, getSpaceAuth, hasUserAuthState, loadExtensionsManifest, loadManagerInstanceIdentity, loadMeshes, manifestExtensionNames, materializeFromManifest, materializeSecretToFile, MEMBERSHIP_RW_CREDS_KIND, mergeLaunchOptions, remintDaemonCreds, resolveOnPath, createManagerInstanceIdentity, spaceMaterialKey, SYSTEM_CREDS_FILES, userAuthStateDir, workspaceSecretStore, writeRenewalRecord } from "@cotal-ai/workspace";
|
|
9
9
|
import { createRuntime, isCustodialRuntime, requireRuntimeAdopt, requireRuntimeReap, RuntimeReapUnproven, } from "./runtime/index.js";
|
|
@@ -147,9 +147,10 @@ function sameStrings(a, b) {
|
|
|
147
147
|
return JSON.stringify([...(a ?? [])].sort()) === JSON.stringify([...(b ?? [])].sort());
|
|
148
148
|
}
|
|
149
149
|
/** Operator-facing phrasing per cause. Kept beside the union so adding a member without a sentence
|
|
150
|
-
* is a type error rather than a blank in the log.
|
|
150
|
+
* is a type error rather than a blank in the log. The stop family's sentences carry the principal
|
|
151
|
+
* from the cause, so they render through {@link STOP_CAUSE_TEXT} under the same exhaustive-Record
|
|
152
|
+
* discipline: a new stop kind without a renderer is a type error too. */
|
|
151
153
|
const FREE_SLOT_CAUSE_TEXT = {
|
|
152
|
-
"stopped": "this manager stopped it (despawn or shutdown)",
|
|
153
154
|
"process-exit": "its own process exited and this manager did not stop it",
|
|
154
155
|
"pi-crash-loop": "this manager retired it after a Pi crash loop",
|
|
155
156
|
"pi-recovery-failed": "this manager retired it after Pi session recovery failed",
|
|
@@ -158,6 +159,21 @@ const FREE_SLOT_CAUSE_TEXT = {
|
|
|
158
159
|
"session-bind-failed": "this manager stopped it: its host session could not be bound at launch",
|
|
159
160
|
"resume-session-rebind-failed": "this manager stopped it: its host session could not be rebound on resume",
|
|
160
161
|
};
|
|
162
|
+
/** The stop family's sentences — the same job {@link FREE_SLOT_CAUSE_TEXT} does for the string
|
|
163
|
+
* causes, beside it so both stay the single place a cause becomes a sentence. A requested stop's
|
|
164
|
+
* sentence embeds the authenticated principal and a recursive reap's embeds the parent that
|
|
165
|
+
* left, so they cannot sit in a static Record keyed by kind; this exhaustive switch is their
|
|
166
|
+
* single home, and the `satisfies never` default keeps the discipline: a new stop kind without a
|
|
167
|
+
* sentence is a type error rather than a blank in the log. */
|
|
168
|
+
function stopCauseText(cause) {
|
|
169
|
+
switch (cause.kind) {
|
|
170
|
+
case "stopped-requested": return `this manager stopped it at ${cause.requester}'s request`;
|
|
171
|
+
case "stopped-self": return "it stopped itself (self-stop)";
|
|
172
|
+
case "stopped-reap": return `this manager stopped it because its parent ${cause.parent} left (recursive reap)`;
|
|
173
|
+
case "stopped-shutdown": return "this manager stopped it on manager shutdown";
|
|
174
|
+
default: return cause;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
161
177
|
function parseTurnNote(raw) {
|
|
162
178
|
let o;
|
|
163
179
|
try {
|
|
@@ -929,7 +945,7 @@ export class Manager {
|
|
|
929
945
|
}
|
|
930
946
|
/** One class-2 renewal pass (D5 slice 5): re-sign `.cotal/delivery.creds` + `.cotal/membership-rw.creds`
|
|
931
947
|
* for their existing nkeys, then request the delivery daemon's EXPLICIT `reloadCreds` adoption on the
|
|
932
|
-
* delivery-admin rail and persist the audit record (`.cotal/renewal
|
|
948
|
+
* delivery-admin rail and persist the audit record (`.cotal/renewal.<spaceKey>.json`) that `cotal doctor auth`
|
|
933
949
|
* renders — so "file re-signed" and "daemon adopted" are distinguishable states. A missing daemon
|
|
934
950
|
* (no responder) is recorded honestly: each daemon's own 75% renewal timer remains the adoption backstop.
|
|
935
951
|
* Never throws — renewal failure must be LOUD (log + record), not fatal to the supervisor. */
|
|
@@ -987,7 +1003,7 @@ export class Manager {
|
|
|
987
1003
|
// A non-owner (#1634) re-signed nothing, so it writes no record: an empty one renders in
|
|
988
1004
|
// `doctor auth` as a pass that never ran.
|
|
989
1005
|
if (this.daemonRenewalOwner)
|
|
990
|
-
writeRenewalRecord(this.workspaceRoot, { ts: new Date().toISOString(), owner: "manager", results, adoption });
|
|
1006
|
+
writeRenewalRecord(this.workspaceRoot, this.space, { ts: new Date().toISOString(), owner: "manager", results, adoption });
|
|
991
1007
|
this.warnOnSystemCredExpiry();
|
|
992
1008
|
// F5(b) (Unit B): the MANAGER is the renewal owner for its managed-static agent creds —
|
|
993
1009
|
// supervisor-side PUSH remint for recorded LIVE slots (the child JWT is never proof of
|
|
@@ -1509,6 +1525,11 @@ export class Manager {
|
|
|
1509
1525
|
this.agents.delete(a.name);
|
|
1510
1526
|
this.stopHandle(a, false);
|
|
1511
1527
|
}
|
|
1528
|
+
// The slot delete above bypasses freeSlot, which is where the reap line lives — so say it here,
|
|
1529
|
+
// once per seat, with the one stop cause that names this path: the shutdown itself. Without
|
|
1530
|
+
// this a `--with-agents` stop left every seat unaccounted for in the log (see #1423).
|
|
1531
|
+
for (const a of managed)
|
|
1532
|
+
this.logSeatReaped(a, { kind: "stopped-shutdown" });
|
|
1512
1533
|
// A runtime stop request is not exit proof. Do not release the manager lease or registration
|
|
1513
1534
|
// while any seat may still hold this instance's broker rails: that creates the exact orphan
|
|
1514
1535
|
// window where a successor sees a dead manager but live predecessor authority. Every shipped
|
|
@@ -2075,6 +2096,34 @@ export class Manager {
|
|
|
2075
2096
|
return a.name;
|
|
2076
2097
|
return caller;
|
|
2077
2098
|
}
|
|
2099
|
+
/** A baseline seat may answer only the pause whose relay is pending on that exact incarnation.
|
|
2100
|
+
* Explicit `run` holders and unmanaged operator instruments keep the run capability's existing
|
|
2101
|
+
* answer reach. The relay payload is manager-held state written by the runtime, never request
|
|
2102
|
+
* input, and its token is checked against the open checkpoint before any answer is filed. */
|
|
2103
|
+
authorizeRunAnswer(ctx, args, open) {
|
|
2104
|
+
const caller = principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
|
|
2105
|
+
const agent = [...this.agents.values()].find((a) => this.managedPrincipal(a) === caller && a.lifecycleUid === ctx.subject.caller.uid);
|
|
2106
|
+
if (agent === undefined || agent.launch.capabilities?.includes("run"))
|
|
2107
|
+
return;
|
|
2108
|
+
const pending = [...this.pendingTurns.values()].find((p) => p.seat.owner === ctx.subject.caller.owner
|
|
2109
|
+
&& p.seat.actor === ctx.subject.caller.actor
|
|
2110
|
+
&& p.seat.uid === ctx.subject.caller.uid);
|
|
2111
|
+
if (pending === undefined)
|
|
2112
|
+
throw new EpEnvelopeError("permission-denied", `run-answer is allowed to this baseline seat only for a pending ask or escalation addressed to its own incarnation; none is pending for ${agent.name}`);
|
|
2113
|
+
let payload;
|
|
2114
|
+
try {
|
|
2115
|
+
payload = JSON.parse(pending.payload);
|
|
2116
|
+
}
|
|
2117
|
+
catch {
|
|
2118
|
+
throw new EpEnvelopeError("permission-denied", `turn "${pending.goalId}" carries no readable ask or escalation authorization; run-answer is refused`);
|
|
2119
|
+
}
|
|
2120
|
+
const p = payload;
|
|
2121
|
+
const addressedToken = typeof p.ask?.token === "string" ? p.ask.token
|
|
2122
|
+
: p.checkpoint?.escalatedTo === agent.name && typeof p.checkpoint?.token === "string" ? p.checkpoint.token
|
|
2123
|
+
: undefined;
|
|
2124
|
+
if (p.run !== args.runId || p.step !== args.stepKey || addressedToken !== open.token)
|
|
2125
|
+
throw new EpEnvelopeError("permission-denied", `run-answer is allowed to ${agent.name} only for the open pause named by its pending ask or escalation relay; ${args.runId} ${args.stepKey} is not that pause`);
|
|
2126
|
+
}
|
|
2078
2127
|
managerServiceDefs() {
|
|
2079
2128
|
const args = (ctx) => (ctx.request.args ?? {});
|
|
2080
2129
|
const callerOf = (ctx) => principalKey(ctx.subject.caller.owner, ctx.subject.caller.actor).key;
|
|
@@ -2144,6 +2193,7 @@ export class Manager {
|
|
|
2144
2193
|
const data = unwrap(await this.opModels(args(ctx)));
|
|
2145
2194
|
return { catalogs: Array.isArray(data) ? data : [data] };
|
|
2146
2195
|
}),
|
|
2196
|
+
resolveCwd: (ctx) => this.serveGated(ctx, () => this.resolveSpawnCwd(args(ctx).cwd)),
|
|
2147
2197
|
// P2 item 2: `spawn` is an ACTION - accept a goal + reply the acceptance floor payload, drive
|
|
2148
2198
|
// progress + terminal off-handler (no ~30s block). The blocking reply path is gone (pin 8).
|
|
2149
2199
|
spawn: (ctx) => this.serveGated(ctx, () => this.serveSpawnGoal(ctx, (h) => this.opStart(args(ctx), callerOf(ctx), h, ctx.subject.route))),
|
|
@@ -2152,7 +2202,7 @@ export class Manager {
|
|
|
2152
2202
|
const denied = await this.authorizeNamed(a, callerOf(ctx), await this.epAnyModeAdmin(ctx), ctx.subject.caller);
|
|
2153
2203
|
if (denied)
|
|
2154
2204
|
throw new EpEnvelopeError("permission-denied", denied);
|
|
2155
|
-
return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true));
|
|
2205
|
+
return unwrap(this.despawnAuthorized(a, args(ctx).graceful !== false, true, callerOf(ctx)));
|
|
2156
2206
|
}),
|
|
2157
2207
|
attach: (ctx) => this.serveGated(ctx, async () => {
|
|
2158
2208
|
const a = targetAgent(ctx);
|
|
@@ -2219,7 +2269,10 @@ export class Manager {
|
|
|
2219
2269
|
// (`run` capability / privileged instrument rows); the serve gate is the maintenance fence.
|
|
2220
2270
|
runStart: (ctx) => this.serveGated(ctx, () => this.runHost().start(ctx, args(ctx))),
|
|
2221
2271
|
runResume: (ctx) => this.serveGated(ctx, () => this.runHost().resume(args(ctx))),
|
|
2222
|
-
runAnswer: (ctx) => this.serveGated(ctx, () =>
|
|
2272
|
+
runAnswer: (ctx) => this.serveGated(ctx, () => {
|
|
2273
|
+
const input = args(ctx);
|
|
2274
|
+
return this.runHost().answer(input, this.runAnswerer(ctx), (open) => this.authorizeRunAnswer(ctx, input, open));
|
|
2275
|
+
}),
|
|
2223
2276
|
runStatus: (ctx) => this.serveGated(ctx, () => this.runHost().status(args(ctx))),
|
|
2224
2277
|
runPs: (ctx) => this.serveGated(ctx, () => this.runHost().list(args(ctx))),
|
|
2225
2278
|
preparePreservation: (ctx) => adminGated(ctx, async () => unwrap(await this.opPreservationCtl("preparePreservation", args(ctx)))),
|
|
@@ -2363,7 +2416,7 @@ export class Manager {
|
|
|
2363
2416
|
return { ok: false, error: `self-stop: caller ${callerId} is not a managed agent` };
|
|
2364
2417
|
const graceful = args.graceful !== false;
|
|
2365
2418
|
this.stopHandle(target, graceful);
|
|
2366
|
-
this.trackStoppedHandle(target, true);
|
|
2419
|
+
this.trackStoppedHandle(target, true, { kind: "stopped-self" });
|
|
2367
2420
|
return { ok: true, data: { name: target.name, stopped: true, graceful } };
|
|
2368
2421
|
}
|
|
2369
2422
|
// Plane-3 durable join/leave/list ops moved OFF the manager onto the server-side delivery daemon's
|
|
@@ -2397,7 +2450,9 @@ export class Manager {
|
|
|
2397
2450
|
}
|
|
2398
2451
|
}
|
|
2399
2452
|
/** Keep an accepted stop inside the lifecycle drain until the runtime proves the child is gone,
|
|
2400
|
-
* so a maintenance prepare can never fence ahead of a child that is still dying.
|
|
2453
|
+
* so a maintenance prepare can never fence ahead of a child that is still dying. The `cause` is
|
|
2454
|
+
* the stop-family cause the door already knows (which door, and who asked); it rides every
|
|
2455
|
+
* freeSlot below so the reap line can say it.
|
|
2401
2456
|
*
|
|
2402
2457
|
* An operator-accepted stop frees its slot at once: `stop` replying ✓ means `ps` no longer lists
|
|
2403
2458
|
* the agent. That cannot omit a still-live child from a cut, because runPreparation drains the
|
|
@@ -2405,7 +2460,7 @@ export class Manager {
|
|
|
2405
2460
|
* slot lingering. A recursive reap (`requireAuthoritativeExit`) instead keeps the slot until the
|
|
2406
2461
|
* wait proves exit: nobody asked for those children to be gone, so they stay managed until the
|
|
2407
2462
|
* runtime says otherwise, and a runtime that cannot prove exit records an unverified stop. */
|
|
2408
|
-
trackStoppedHandle(a, floor, requireAuthoritativeExit = false) {
|
|
2463
|
+
trackStoppedHandle(a, floor, cause, requireAuthoritativeExit = false) {
|
|
2409
2464
|
if (!a.handle.waitForExit) {
|
|
2410
2465
|
// Preserve ordinary external-runtime stop behavior, but retain enough evidence for a later
|
|
2411
2466
|
// maintenance prepare to fail if that runtime still cannot prove the surface disappeared.
|
|
@@ -2420,14 +2475,14 @@ export class Manager {
|
|
|
2420
2475
|
});
|
|
2421
2476
|
if (requireAuthoritativeExit)
|
|
2422
2477
|
return;
|
|
2423
|
-
this.freeSlot(a, floor,
|
|
2478
|
+
this.freeSlot(a, floor, cause, true);
|
|
2424
2479
|
return;
|
|
2425
2480
|
}
|
|
2426
2481
|
if (!requireAuthoritativeExit)
|
|
2427
|
-
this.freeSlot(a, floor,
|
|
2482
|
+
this.freeSlot(a, floor, cause, true);
|
|
2428
2483
|
this.lifecycleInFlight++;
|
|
2429
2484
|
void this.awaitHandleExit(a.handle)
|
|
2430
|
-
.then(() => this.freeSlot(a, floor,
|
|
2485
|
+
.then(() => this.freeSlot(a, floor, cause, true)) // no-op once an accepted stop already freed it
|
|
2431
2486
|
.catch((e) => {
|
|
2432
2487
|
this.unverifiedStops.push({
|
|
2433
2488
|
name: a.name,
|
|
@@ -2564,6 +2619,7 @@ export class Manager {
|
|
|
2564
2619
|
* that could not provide it.
|
|
2565
2620
|
*/
|
|
2566
2621
|
logSeatReaped(a, cause) {
|
|
2622
|
+
const causeText = typeof cause === "string" ? FREE_SLOT_CAUSE_TEXT[cause] : stopCauseText(cause);
|
|
2567
2623
|
let detail;
|
|
2568
2624
|
try {
|
|
2569
2625
|
const info = a.handle.exitInfo?.();
|
|
@@ -2576,7 +2632,7 @@ export class Manager {
|
|
|
2576
2632
|
// log line (or the free path it sits on) down with it.
|
|
2577
2633
|
detail = `exit detail unreadable from runtime "${a.handle.kind}": ${e.message}`;
|
|
2578
2634
|
}
|
|
2579
|
-
console.error(`seat reaped: ${a.name} (${a.id}, uid ${a.lifecycleUid}${a.handle.pid !== undefined ? `, pid ${a.handle.pid}` : ""}) - ${
|
|
2635
|
+
console.error(`seat reaped: ${a.name} (${a.id}, uid ${a.lifecycleUid}${a.handle.pid !== undefined ? `, pid ${a.handle.pid}` : ""}) - ${causeText}; ${detail}`);
|
|
2580
2636
|
}
|
|
2581
2637
|
freeSlot(a, floor, cause, acceptedBeforeFence = false) {
|
|
2582
2638
|
if (this.agents.get(a.name) !== a)
|
|
@@ -2939,7 +2995,7 @@ export class Manager {
|
|
|
2939
2995
|
continue;
|
|
2940
2996
|
this.reapChildrenOf(this.managedPrincipal(child));
|
|
2941
2997
|
this.stopHandle(child, false);
|
|
2942
|
-
this.trackStoppedHandle(child, true, true);
|
|
2998
|
+
this.trackStoppedHandle(child, true, { kind: "stopped-reap", parent: parentId }, true);
|
|
2943
2999
|
}
|
|
2944
3000
|
}
|
|
2945
3001
|
readSessionStatePath(path) {
|
|
@@ -3301,6 +3357,33 @@ export class Manager {
|
|
|
3301
3357
|
route,
|
|
3302
3358
|
}, caller, hooks);
|
|
3303
3359
|
}
|
|
3360
|
+
/** Resolve a placed spawn directory on this manager's host. The manager serves any existing
|
|
3361
|
+
* absolute directory it can resolve. It creates nothing and never substitutes the workspace root. */
|
|
3362
|
+
resolveSpawnCwd(value) {
|
|
3363
|
+
const asked = typeof value === "string" ? value : String(value ?? "");
|
|
3364
|
+
const refuse = (reason) => {
|
|
3365
|
+
throw new EpEnvelopeError("failed-precondition", `cwd does not resolve on this host: ${JSON.stringify(asked)} (${reason})`);
|
|
3366
|
+
};
|
|
3367
|
+
if (!isAbsolute(asked))
|
|
3368
|
+
refuse("expected an absolute path");
|
|
3369
|
+
let cwd = asked;
|
|
3370
|
+
try {
|
|
3371
|
+
cwd = realpathSync(asked);
|
|
3372
|
+
}
|
|
3373
|
+
catch (error) {
|
|
3374
|
+
refuse(`path cannot be resolved: ${error.message}`);
|
|
3375
|
+
}
|
|
3376
|
+
try {
|
|
3377
|
+
if (!statSync(cwd).isDirectory())
|
|
3378
|
+
refuse("path is not a directory");
|
|
3379
|
+
}
|
|
3380
|
+
catch (error) {
|
|
3381
|
+
if (error instanceof EpEnvelopeError)
|
|
3382
|
+
throw error;
|
|
3383
|
+
refuse(error.message);
|
|
3384
|
+
}
|
|
3385
|
+
return { cwd, host: hostname() };
|
|
3386
|
+
}
|
|
3304
3387
|
/** Resolve a connector by agent type. Library composition (installedExtensions off) → a registry
|
|
3305
3388
|
* hit, exactly as before (the composition root imported what it wants). The published binary gates
|
|
3306
3389
|
* on MANIFEST membership FIRST — so a live `cotal ext remove` is honored even though the registry
|
|
@@ -4935,13 +5018,15 @@ export class Manager {
|
|
|
4935
5018
|
const denied = await this.authorizeNamed(a, caller, admin);
|
|
4936
5019
|
if (denied)
|
|
4937
5020
|
return { ok: false, error: denied };
|
|
4938
|
-
return this.despawnAuthorized(a, graceful, !admin);
|
|
5021
|
+
return this.despawnAuthorized(a, graceful, !admin, caller);
|
|
4939
5022
|
}
|
|
4940
5023
|
/** The post-authorization terminal effect (both doors). `trackNonAdmin` mirrors the ctl door's
|
|
4941
|
-
* `trackStoppedHandle(a, !admin)` disposition.
|
|
4942
|
-
|
|
5024
|
+
* `trackStoppedHandle(a, !admin)` disposition. The reap line names `caller` — the AUTHENTICATED
|
|
5025
|
+
* wire principal the door already resolved — so an operator reading the log can tell one peer's
|
|
5026
|
+
* despawn from another's. */
|
|
5027
|
+
despawnAuthorized(a, graceful, trackNonAdmin, caller) {
|
|
4943
5028
|
this.stopHandle(a, graceful);
|
|
4944
|
-
this.trackStoppedHandle(a, trackNonAdmin);
|
|
5029
|
+
this.trackStoppedHandle(a, trackNonAdmin, { kind: "stopped-requested", requester: caller });
|
|
4945
5030
|
void this.cancelAgentGoal(a.name, graceful ? "graceful" : "terminate"); // M4: cancel a live spawn goal
|
|
4946
5031
|
return { ok: true, data: { name: a.name, stopped: true, graceful } };
|
|
4947
5032
|
}
|