@danypops/papyrus 0.48.0 → 0.49.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/package.json +2 -2
- package/src/cli/daemon-command.ts +48 -0
- package/src/cli.ts +8 -2
- package/src/client.ts +6 -0
- package/src/constants.ts +1 -0
- package/src/daemon/daemon-state.ts +13 -1
- package/src/daemon/daemon.ts +25 -6
- package/src/ops.ts +22 -10
- package/src/service.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.1",
|
|
4
4
|
"description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@danypops/vehicle-client": "^0.6.1",
|
|
37
37
|
"@danypops/vehicle-core": "^0.12.3",
|
|
38
|
-
"@danypops/vehicle-server": "^0.
|
|
38
|
+
"@danypops/vehicle-server": "^0.19.0",
|
|
39
39
|
"@stricli/core": "^1.3.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
2
|
+
import type { CommandContext } from "@stricli/core";
|
|
3
|
+
import { buildApplication, buildCommand, buildRouteMap } from "@stricli/core";
|
|
4
|
+
import { runStricliToString } from "./stricli-run.ts";
|
|
5
|
+
|
|
6
|
+
export interface DaemonDiagnoseClient {
|
|
7
|
+
diagnose(): Promise<DaemonDiagnosis>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DaemonContext extends CommandContext {
|
|
11
|
+
readonly client: DaemonDiagnoseClient;
|
|
12
|
+
readonly json: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function renderHistoryLine(event: DaemonDiagnosis["history"][number]): string {
|
|
16
|
+
const reason = event.reason ? ` (${event.reason})` : "";
|
|
17
|
+
return `${event.at} ${event.type}${reason} pid=${event.pid} instance=${event.instanceId}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const diagnoseCommand = buildCommand({
|
|
21
|
+
func: async function (this: DaemonContext) {
|
|
22
|
+
const diagnosis = await this.client.diagnose();
|
|
23
|
+
if (this.json) {
|
|
24
|
+
this.process.stdout.write(JSON.stringify(diagnosis));
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const lines = [
|
|
28
|
+
`instance ${diagnosis.instanceId} (pid ${diagnosis.pid}, ${diagnosis.provenance}), started ${diagnosis.startedAt}`,
|
|
29
|
+
"",
|
|
30
|
+
"recent history:",
|
|
31
|
+
...(diagnosis.history.length === 0 ? [" (none)"] : diagnosis.history.map((event) => ` ${renderHistoryLine(event)}`)),
|
|
32
|
+
];
|
|
33
|
+
this.process.stdout.write(lines.join("\n"));
|
|
34
|
+
},
|
|
35
|
+
parameters: { flags: {} },
|
|
36
|
+
docs: { brief: "Show this daemon's identity and recent start/stop/already_running history" },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const app = buildApplication(buildRouteMap({ routes: { diagnose: diagnoseCommand }, docs: { brief: "Daemon operations" } }), {
|
|
40
|
+
name: "daemon",
|
|
41
|
+
scanner: { caseStyle: "allow-kebab-for-camel" },
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export async function runDaemonCli(args: string[], client: DaemonDiagnoseClient): Promise<string> {
|
|
45
|
+
const json = args.includes("--json");
|
|
46
|
+
const positional = args.filter((arg) => arg !== "--json");
|
|
47
|
+
return runStricliToString(app, positional, { client, json });
|
|
48
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { copyFileSync, existsSync, readFileSync, renameSync, unlinkSync, writeFi
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createNodeServiceInstallDeps, generateSystemdUnit, installUserService, type ServiceSpec } from "@danypops/vehicle-server/service";
|
|
6
6
|
import { runArtifactCli } from "./cli/artifact-command.ts";
|
|
7
|
+
import { runDaemonCli } from "./cli/daemon-command.ts";
|
|
7
8
|
import { runDiscussCli } from "./cli/discuss-command.ts";
|
|
8
9
|
import { runDocsCli } from "./cli/docs-command.ts";
|
|
9
10
|
import { runGatesCli } from "./cli/gates-command.ts";
|
|
@@ -206,7 +207,7 @@ function usage(): never {
|
|
|
206
207
|
process.exit(2);
|
|
207
208
|
}
|
|
208
209
|
|
|
209
|
-
export { runMigrationCli };
|
|
210
|
+
export { runDaemonCli, runMigrationCli };
|
|
210
211
|
|
|
211
212
|
function readIdMap(sidecarPath: string): IdMigrationPlan {
|
|
212
213
|
const raw = JSON.parse(readFileSync(sidecarPath, "utf8")) as { idMap: Record<string, string> };
|
|
@@ -366,7 +367,7 @@ export {
|
|
|
366
367
|
export async function main(args: string[] = process.argv.slice(2)): Promise<void> {
|
|
367
368
|
const [command, action] = args;
|
|
368
369
|
if (command === "serve") {
|
|
369
|
-
serveMain();
|
|
370
|
+
await serveMain();
|
|
370
371
|
return;
|
|
371
372
|
}
|
|
372
373
|
if (command === "tasks") {
|
|
@@ -404,6 +405,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
|
|
|
404
405
|
console.log(await runMigrationCli(args.slice(1), client));
|
|
405
406
|
return;
|
|
406
407
|
}
|
|
408
|
+
if (command === "daemon") {
|
|
409
|
+
const client = await connectPapyrusClient();
|
|
410
|
+
console.log(await runDaemonCli(args.slice(1), client));
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
407
413
|
if (command === "migrate-ids") {
|
|
408
414
|
console.log(runIdMigrationCli(args.slice(1)));
|
|
409
415
|
return;
|
package/src/client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
spawnDetachedDaemon,
|
|
8
8
|
} from "@danypops/vehicle-client/daemon-client";
|
|
9
9
|
import { createLiveVersionExpectation } from "@danypops/vehicle-client/version";
|
|
10
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
10
11
|
import { DAEMON_CLIENT_TIMEOUT_MS, DAEMON_DIR_ENV, DAEMON_PROBE_TIMEOUT_MS } from "./constants.ts";
|
|
11
12
|
import { type DaemonHandle, daemonStateDir, readDaemonHandle } from "./daemon/daemon-state.ts";
|
|
12
13
|
import type { OperationName, SchemaState } from "./service.ts";
|
|
@@ -50,6 +51,11 @@ export class PapyrusClient {
|
|
|
50
51
|
return this.request("/health");
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** Backed by GET /daemon/diagnose -- see service.ts's createApp and vehicle-server's daemon-lifecycle.ts. */
|
|
55
|
+
diagnose(): Promise<DaemonDiagnosis> {
|
|
56
|
+
return this.request("/daemon/diagnose");
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
async operations(): Promise<OperationName[]> {
|
|
54
60
|
const body = await this.request<{ operations: OperationName[] }>("/api/v1/ops");
|
|
55
61
|
return body.operations;
|
package/src/constants.ts
CHANGED
|
@@ -4,6 +4,7 @@ export const DAEMON_PORT_FILE = "port";
|
|
|
4
4
|
export const DAEMON_TOKEN_FILE = "token";
|
|
5
5
|
/** vehicle-server's own {host,port,pid} handle format -- read by Armada's readiness probe once Papyrus is service-installed, see cli.ts's papyrusServiceSpec. */
|
|
6
6
|
export const DAEMON_HANDLE_FILE = "vehicle-handle.json";
|
|
7
|
+
export const DAEMON_LIFECYCLE_FILE = "lifecycle.json";
|
|
7
8
|
export const DAEMON_CLIENT_TIMEOUT_MS = 15_000;
|
|
8
9
|
export const DAEMON_PROBE_TIMEOUT_MS = 800;
|
|
9
10
|
export const DAEMON_UNIT_NAME = "papyrus.service";
|
|
@@ -3,7 +3,14 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { LOOPBACK_HOST, removeDaemonHandle, writeDaemonHandle } from "@danypops/vehicle-server/paths";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
DAEMON_DIR_ENV,
|
|
8
|
+
DAEMON_HANDLE_FILE,
|
|
9
|
+
DAEMON_HOST,
|
|
10
|
+
DAEMON_LIFECYCLE_FILE,
|
|
11
|
+
DAEMON_PORT_FILE,
|
|
12
|
+
DAEMON_TOKEN_FILE,
|
|
13
|
+
} from "../constants.ts";
|
|
7
14
|
|
|
8
15
|
export interface DaemonHandle {
|
|
9
16
|
baseUrl: string;
|
|
@@ -48,6 +55,11 @@ export function vehicleHandlePath(dir: string): string {
|
|
|
48
55
|
return join(dir, DAEMON_HANDLE_FILE);
|
|
49
56
|
}
|
|
50
57
|
|
|
58
|
+
/** Where the structured daemon lifecycle event log (@danypops/vehicle-server's daemon-lifecycle.ts) persists start/stop/already_running history across restarts -- see daemon.ts's diagnose wiring. */
|
|
59
|
+
export function lifecyclePath(dir: string): string {
|
|
60
|
+
return join(dir, DAEMON_LIFECYCLE_FILE);
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
/** vehicle-server's own {host,port,pid} handle format, distinct from this file's port/token pair -- Armada's readiness probe (createHandleReadinessProbe) reads exactly this shape. */
|
|
52
64
|
export function writeVehicleHandle(dir: string, port: number, pid: number = process.pid): void {
|
|
53
65
|
writeDaemonHandle(vehicleHandlePath(dir), { host: LOOPBACK_HOST, port, pid });
|
package/src/daemon/daemon.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { join } from "node:path";
|
|
3
|
+
import { createNodeAtomicJsonFsAdapter } from "@danypops/vehicle-server/atomic-json";
|
|
4
|
+
import { readLaunchProvenance } from "@danypops/vehicle-server/daemon";
|
|
5
|
+
import { diagnoseDaemon, openDaemonLifecycleLog } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
2
6
|
import { acquireDaemonLock, releaseDaemonLock } from "@danypops/vehicle-server/paths";
|
|
3
7
|
import { PushChannel } from "@danypops/vehicle-server/push-channel";
|
|
4
8
|
import { DAEMON_HOST, DB_OPTIMIZE_INTERVAL_MS, dbPath, WAL_CHECKPOINT_INTERVAL_MS } from "../constants.ts";
|
|
@@ -8,6 +12,7 @@ import {
|
|
|
8
12
|
clearDaemonPort,
|
|
9
13
|
clearVehicleHandle,
|
|
10
14
|
daemonStateDir,
|
|
15
|
+
lifecyclePath,
|
|
11
16
|
loadOrCreateToken,
|
|
12
17
|
writeDaemonPort,
|
|
13
18
|
writeVehicleHandle,
|
|
@@ -35,14 +40,26 @@ const TASK_READ_ONLY_OPERATIONS = new Set([
|
|
|
35
40
|
]);
|
|
36
41
|
|
|
37
42
|
/** Start the supervised, long-running Papyrus service. */
|
|
38
|
-
export function serveMain(): void {
|
|
43
|
+
export async function serveMain(): Promise<void> {
|
|
39
44
|
const stateDir = daemonStateDir();
|
|
40
45
|
const lockPath = join(stateDir, "daemon.lock");
|
|
46
|
+
const instanceId = randomUUID();
|
|
47
|
+
const provenance = readLaunchProvenance();
|
|
48
|
+
const lifecycleLog = openDaemonLifecycleLog({ path: lifecyclePath(stateDir), fs: createNodeAtomicJsonFsAdapter() });
|
|
49
|
+
const recordLifecycle = async (type: "started" | "already_running" | "stopped", reason?: string): Promise<void> => {
|
|
50
|
+
try {
|
|
51
|
+
await lifecycleLog.record({ instanceId, pid: process.pid, type, provenance, reason });
|
|
52
|
+
} catch (error) {
|
|
53
|
+
logEvent("error", "lifecycle_log_record_failed", { message: error instanceof Error ? error.message : String(error) });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
41
56
|
const lock = acquireDaemonLock(lockPath);
|
|
42
57
|
if (!lock.acquired) {
|
|
43
58
|
logEvent("info", "already_running", { holderPid: lock.holderPid });
|
|
59
|
+
await recordLifecycle("already_running", lock.holderPid === null ? undefined : `holder pid ${lock.holderPid}`);
|
|
44
60
|
return;
|
|
45
61
|
}
|
|
62
|
+
const startedAt = new Date().toISOString();
|
|
46
63
|
const token = loadOrCreateToken(stateDir);
|
|
47
64
|
const service = createPapyrusService(dbPath());
|
|
48
65
|
const pushChannel = new PushChannel({ token });
|
|
@@ -55,6 +72,7 @@ export function serveMain(): void {
|
|
|
55
72
|
}
|
|
56
73
|
},
|
|
57
74
|
logger: vehicleLogger(),
|
|
75
|
+
diagnose: () => diagnoseDaemon({ lifecycleLog, current: { instanceId, pid: process.pid, startedAt, provenance } }),
|
|
58
76
|
});
|
|
59
77
|
const server = Bun.serve({
|
|
60
78
|
hostname: DAEMON_HOST,
|
|
@@ -110,7 +128,7 @@ export function serveMain(): void {
|
|
|
110
128
|
}
|
|
111
129
|
}, DB_OPTIMIZE_INTERVAL_MS);
|
|
112
130
|
let stopping = false;
|
|
113
|
-
const shutdown = () => {
|
|
131
|
+
const shutdown = (signal: string) => {
|
|
114
132
|
if (stopping) return;
|
|
115
133
|
stopping = true;
|
|
116
134
|
clearInterval(checkpointTimer);
|
|
@@ -123,12 +141,13 @@ export function serveMain(): void {
|
|
|
123
141
|
service.close();
|
|
124
142
|
// .finally() re-throws rather than handling a rejection -- catching it first turns a bare
|
|
125
143
|
// unhandled-rejection warning into a real, queryable shutdown-failure log line.
|
|
126
|
-
void
|
|
127
|
-
.stop(true)
|
|
144
|
+
void recordLifecycle("stopped", signal)
|
|
145
|
+
.then(() => server.stop(true))
|
|
128
146
|
.catch((error) => logEvent("error", "server_stop_failed", { message: error instanceof Error ? error.message : String(error) }))
|
|
129
147
|
.finally(() => process.exit(0));
|
|
130
148
|
};
|
|
131
|
-
process.on("SIGINT", shutdown);
|
|
132
|
-
process.on("SIGTERM", shutdown);
|
|
149
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
150
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
133
151
|
logEvent("info", "listening", { host: DAEMON_HOST, port: server.port });
|
|
152
|
+
await recordLifecycle("started");
|
|
134
153
|
}
|
package/src/ops.ts
CHANGED
|
@@ -599,22 +599,34 @@ function readBoundedGateFile(path: string): string {
|
|
|
599
599
|
return readFileSync(path, "utf-8") as string;
|
|
600
600
|
}
|
|
601
601
|
|
|
602
|
-
/**
|
|
603
|
-
*
|
|
602
|
+
/**
|
|
603
|
+
* Shared by the sync and async process-gate runners so "test" is never a second, independently
|
|
604
|
+
* maintained copy of "command"'s own command-template selection.
|
|
605
|
+
*
|
|
606
|
+
* "test" runs `gate.target` verbatim, exactly like "command" -- the only real difference is a
|
|
607
|
+
* more generous default timeout (GATE_TEST_TIMEOUT_MS vs GATE_COMMAND_TIMEOUT_MS), since a test
|
|
608
|
+
* suite routinely runs longer than an arbitrary command. It previously wrapped target in
|
|
609
|
+
* `npx vitest run ${target} --reporter=dot`, silently wrong for every real consumer in this
|
|
610
|
+
* ecosystem (all Bun-native, none use vitest): a target that was itself a full command (e.g.
|
|
611
|
+
* `bun test path/to.test.ts`, exactly what every existing gate/checklist example here has always
|
|
612
|
+
* shown) got parsed by vitest as three separate positional args, triggering vitest's own broad
|
|
613
|
+
* discovery across the whole repo instead of running the intended command at all -- a real
|
|
614
|
+
* incident (task ab1463e2) that produced an unrelated multi-suite vitest failure cascade instead
|
|
615
|
+
* of the actual target ever running.
|
|
616
|
+
*/
|
|
604
617
|
function processGateCommand(gate: Gate): { command: string; timeout: number } {
|
|
605
|
-
if (gate.type === "test")
|
|
606
|
-
return { command: `npx vitest run ${gate.target} --reporter=dot`, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
|
|
618
|
+
if (gate.type === "test") return { command: gate.target, timeout: gate.timeoutMs ?? GATE_TEST_TIMEOUT_MS };
|
|
607
619
|
return { command: gate.target, timeout: gate.timeoutMs ?? GATE_COMMAND_TIMEOUT_MS };
|
|
608
620
|
}
|
|
609
621
|
|
|
610
622
|
/**
|
|
611
623
|
* spawnSync + manual stdout/stderr concatenation, not execSync: execSync's return value is stdout
|
|
612
|
-
* only. Many real commands (bun test's own per-test lines and its pass/fail summary among them
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
*
|
|
617
|
-
*
|
|
624
|
+
* only. Many real commands (bun test's own per-test lines and its pass/fail summary among them)
|
|
625
|
+
* write their actual output to stderr, so an execSync-based match against gate.expect saw only the
|
|
626
|
+
* first line of a banner and never the result -- every such gate failed regardless of whether the
|
|
627
|
+
* command actually passed. This one function now serves both "command" and "test" gates;
|
|
628
|
+
* previously "test" was a second, separately-maintained execSync path that never checked
|
|
629
|
+
* gate.expect at all.
|
|
618
630
|
*/
|
|
619
631
|
function runProcessGateSync(gate: Gate, cwd?: string): GateResult {
|
|
620
632
|
const { spawnSync } = require_("node:child_process");
|
package/src/service.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { VehicleError } from "@danypops/vehicle-core";
|
|
2
2
|
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
3
|
+
import type { DaemonDiagnosis } from "@danypops/vehicle-server/daemon-lifecycle";
|
|
3
4
|
import { createVehicleHttpApp } from "@danypops/vehicle-server/http";
|
|
4
5
|
import type { Logger } from "@danypops/vehicle-server/logging";
|
|
5
6
|
import type { CreateArtifactInput } from "./artifact/artifact.ts";
|
|
@@ -577,6 +578,14 @@ export function createApp(deps: {
|
|
|
577
578
|
onOperationExecuted?: (operation: string, input: OperationInput) => void;
|
|
578
579
|
/** Defaults to a no-op (createVehicleHttpApp's own default) -- daemon.ts wires vehicleLogger() so a failed invocation is actually logged, not silently discarded. */
|
|
579
580
|
logger?: Logger;
|
|
581
|
+
/**
|
|
582
|
+
* Backs GET /daemon/diagnose -- "who am I, and what happened recently" (see
|
|
583
|
+
* @danypops/vehicle-server's daemon-lifecycle.ts), without a caller reading Papyrus's own
|
|
584
|
+
* SQLite database or state files directly. Omitted (e.g. in most tests, which don't run a
|
|
585
|
+
* real supervised daemon process) means the route 404s, matching how /health always exists
|
|
586
|
+
* but this diagnostic identity does not until a real serveMain() supplies it.
|
|
587
|
+
*/
|
|
588
|
+
diagnose?: () => Promise<DaemonDiagnosis>;
|
|
580
589
|
}): { fetch(request: Request): Promise<Response> } {
|
|
581
590
|
// Same Bearer token, daemon, and port as the rest of this API -- see ./handlers/registry.ts.
|
|
582
591
|
const vehicleApp = createVehicleHttpApp({ registry: deps.service.vehicle, token: deps.token, logger: deps.logger });
|
|
@@ -606,6 +615,10 @@ export function createApp(deps: {
|
|
|
606
615
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
607
616
|
return json({ ok: true, version: VERSION, schema: deps.service.schemaState() });
|
|
608
617
|
}
|
|
618
|
+
if (request.method === "GET" && url.pathname === "/daemon/diagnose") {
|
|
619
|
+
if (!deps.diagnose) return json({ error: "daemon diagnose is unavailable on this instance" }, { status: 404 });
|
|
620
|
+
return json(await deps.diagnose());
|
|
621
|
+
}
|
|
609
622
|
if (request.method === "GET" && url.pathname === "/api/v1/ops") {
|
|
610
623
|
return json({ operations: deps.service.operationNames() });
|
|
611
624
|
}
|