@wirecat/cli-core 0.466.0 → 0.467.0
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/CHANGELOG.md +36 -0
- package/README.md +4 -2
- package/dist/cli/command-runs.d.ts +38 -10
- package/dist/cli/command-runs.js +51 -17
- package/dist/cli/context.d.ts +3 -0
- package/dist/cli/context.js +15 -3
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +1 -1
- package/dist/cli/messenger/context.js +1 -1
- package/dist/cli/messenger/observed.js +10 -2
- package/dist/cli/messenger/report.js +48 -3
- package/dist/cli/program.d.ts +1 -1
- package/dist/cli/program.js +7 -3
- package/dist/cli/settings.d.ts +19 -6
- package/dist/cli/settings.js +14 -3
- package/dist/cli/shared-command-exports.d.ts +12 -4
- package/dist/entities/index.d.ts +39 -0
- package/dist/entities/recorded-run.d.ts +39 -0
- package/dist/entities/recorded-run.js +14 -1
- package/dist/mcp/agent.d.ts +3 -0
- package/dist/mcp/agent.js +2 -0
- package/dist/mcp/all-profiles.d.ts +2 -0
- package/dist/mcp/all-profiles.js +16 -3
- package/dist/mcp/bot/server.js +8 -3
- package/dist/mcp/caller.d.ts +38 -0
- package/dist/mcp/caller.js +95 -0
- package/dist/mcp/http/oauth.js +5 -1
- package/dist/mcp/server.js +19 -7
- package/dist/mcp/surface.js +11 -2
- package/dist/mcp/tool.d.ts +4 -1
- package/dist/mcp/tool.js +21 -11
- package/dist/runs/events.d.ts +16 -4
- package/dist/runs/events.js +4 -0
- package/dist/runs/log-attributes.js +7 -1
- package/dist/runs/log-events.js +5 -0
- package/dist/runs/recording.d.ts +10 -3
- package/dist/runs/recording.js +59 -15
- package/dist/runs/run-context.d.ts +55 -4
- package/dist/runs/run-context.js +34 -3
- package/dist/runs/run.d.ts +37 -11
- package/dist/runs/run.js +201 -52
- package/dist/runs/scope.d.ts +12 -0
- package/dist/runs/scope.js +6 -0
- package/dist/services/patience.js +5 -1
- package/package.json +1 -1
package/dist/runs/run.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Logger } from "@wirecat/cli-base";
|
|
2
2
|
import type { AppIdentity } from "../ports/app.js";
|
|
3
3
|
import type { RunError } from "./error-detail.js";
|
|
4
|
-
import type { RunAgent } from "./run-context.js";
|
|
4
|
+
import type { RunAgent, RunMcp, RunVia } from "./run-context.js";
|
|
5
5
|
export type RunStatus = "success" | "failed" | "partial";
|
|
6
6
|
/**
|
|
7
7
|
* What a run was, and how it ended. **It never says what was read or sent** — a command, a profile,
|
|
@@ -18,8 +18,10 @@ export interface RunMetadata {
|
|
|
18
18
|
startedAt: string;
|
|
19
19
|
completedAt?: string;
|
|
20
20
|
status: RunStatus | "running";
|
|
21
|
-
/** How many
|
|
21
|
+
/** How many calls reached the messenger: wire calls when the transport logs them, otherwise operations. */
|
|
22
22
|
requests?: number;
|
|
23
|
+
/** Events past the run's cap, counted rather than written; an `events.dropped` line says which. */
|
|
24
|
+
eventsDropped?: number;
|
|
23
25
|
durationMs?: number;
|
|
24
26
|
cliVersion: string;
|
|
25
27
|
/** `node 24.1.0` or `bun 1.3.14`, and where: what a problem report is asked for first. */
|
|
@@ -53,13 +55,17 @@ export interface RunMetadata {
|
|
|
53
55
|
argv?: string[];
|
|
54
56
|
/** The word that named no command, when that is why the run failed. */
|
|
55
57
|
unknownCommand?: string;
|
|
56
|
-
/** The coding agent that started it, when one did. */
|
|
58
|
+
/** The coding agent that started it, when one did, and the MCP client that made the call. */
|
|
57
59
|
agent?: RunAgent;
|
|
60
|
+
/** How it was asked for (`viaOf`, or the MCP server that received it). */
|
|
61
|
+
via?: RunVia;
|
|
62
|
+
/** The MCP call it answered. */
|
|
63
|
+
mcp?: RunMcp;
|
|
58
64
|
/** How a failed run failed (`errorDetailOf`). */
|
|
59
65
|
error?: RunError;
|
|
60
66
|
}
|
|
61
67
|
/** Who started a run and with what, known before it starts. */
|
|
62
|
-
export type RunContext = Pick<RunMetadata, "cliCommand" | "argv" | "unknownCommand" | "agent">;
|
|
68
|
+
export type RunContext = Pick<RunMetadata, "cliCommand" | "argv" | "unknownCommand" | "agent" | "via" | "mcp">;
|
|
63
69
|
export interface StartRunOptions {
|
|
64
70
|
runsDir: string;
|
|
65
71
|
command: string;
|
|
@@ -69,6 +75,15 @@ export interface StartRunOptions {
|
|
|
69
75
|
cliVersion: string;
|
|
70
76
|
/** Older runs are removed as this one starts. */
|
|
71
77
|
keepDays?: number;
|
|
78
|
+
/** Successful runs older than this are removed sooner than the failures `keepDays` keeps. */
|
|
79
|
+
keepSuccessDays?: number;
|
|
80
|
+
/** The runs folder's size limit in bytes: past it, the oldest successful runs go first, then the oldest days. */
|
|
81
|
+
maxBytes?: number;
|
|
82
|
+
/**
|
|
83
|
+
* Opens the directory only when there is something to put in it: an event, or an ending other than a plain
|
|
84
|
+
* success. A success with no events is then one line in the day's index (`runs show` reads it from there).
|
|
85
|
+
*/
|
|
86
|
+
lazy?: boolean;
|
|
72
87
|
runId?: string;
|
|
73
88
|
now?: () => Date;
|
|
74
89
|
/** When the command began, if that was before the run was opened — a failure kept after the fact. */
|
|
@@ -90,6 +105,8 @@ export interface Run {
|
|
|
90
105
|
finish(status: RunStatus, extra?: Partial<RunMetadata>): Promise<void>;
|
|
91
106
|
}
|
|
92
107
|
export declare const KEEP_RUNS_FOR_DAYS = 30;
|
|
108
|
+
export declare const KEEP_SUCCESSFUL_RUNS_FOR_DAYS = 7;
|
|
109
|
+
export declare const RUNS_SIZE_LIMIT_MB = 200;
|
|
93
110
|
/** `<state dir>/runs` — never the cache directory: a cache is deleted to fix a cache, and a run cannot be rebuilt. */
|
|
94
111
|
export declare const runsDirFor: (app: AppIdentity, env?: NodeJS.ProcessEnv) => string;
|
|
95
112
|
/**
|
|
@@ -101,14 +118,19 @@ export declare const runsDirFor: (app: AppIdentity, env?: NodeJS.ProcessEnv) =>
|
|
|
101
118
|
* events.jsonl one log record per event (cli-base's `LogRecord`), no ANSI ever
|
|
102
119
|
* ```
|
|
103
120
|
*
|
|
104
|
-
* `run.json` is written
|
|
121
|
+
* `run.json` is written as the directory opens, with `status: "running"`, and again by `finish`. A directory
|
|
105
122
|
* holding events and no metadata is a special case `runs list` would carry forever.
|
|
106
123
|
*
|
|
107
124
|
* Each write also appends the metadata to the day's `index.jsonl`, which `runs list` and `search runs`
|
|
108
|
-
* read instead of every `run.json`. It is only
|
|
109
|
-
* the slow way.
|
|
125
|
+
* read instead of every `run.json`. It is the only record of a `lazy` run that succeeded without events.
|
|
110
126
|
*/
|
|
111
127
|
export declare const startRun: (options: StartRunOptions) => Run;
|
|
128
|
+
/**
|
|
129
|
+
* The test sandbox moves HOME and the XDG folders, so a run that still resolves into the owner's own data folder is a
|
|
130
|
+
* fixture that bypassed it: thousands of test runs once landed beside the owner's real ones. The passwd entry's home
|
|
131
|
+
* is read because HOME is the very thing the sandbox changed.
|
|
132
|
+
*/
|
|
133
|
+
export declare const refuseRealStateInTests: (dir: string, env?: NodeJS.ProcessEnv) => void;
|
|
112
134
|
export declare const runtime: () => string;
|
|
113
135
|
/** `20260920T084431Z-chats-list-a81f2c` — sortable, and readable without opening it. */
|
|
114
136
|
export declare const newRunId: (startedAt: Date, command: string) => string;
|
|
@@ -138,12 +160,16 @@ export declare const findRun: (runsDir: string, id: string) => {
|
|
|
138
160
|
*/
|
|
139
161
|
export declare const readEvents: (dir: string) => Record<string, unknown>[];
|
|
140
162
|
/**
|
|
141
|
-
* Removes every day older than `keepDays`,
|
|
163
|
+
* Removes every day older than `keepDays`, then the successful runs of every day older than `keepSuccessDays`, and
|
|
164
|
+
* answers how many days went. Then, once a day, the folder is measured against `maxBytes`: successful runs go first,
|
|
165
|
+
* oldest day first, then whole days, until it fits.
|
|
142
166
|
*
|
|
143
|
-
* A
|
|
144
|
-
*
|
|
167
|
+
* A day at a time, by the directory name and the day's index, so no run has to be opened to decide — and a directory
|
|
168
|
+
* that is not a date is left alone rather than guessed at.
|
|
145
169
|
*/
|
|
146
|
-
export declare const pruneRuns: (runsDir: string, { keepDays, now }: {
|
|
170
|
+
export declare const pruneRuns: (runsDir: string, { keepDays, keepSuccessDays, maxBytes, now, }: {
|
|
147
171
|
keepDays: number;
|
|
172
|
+
keepSuccessDays?: number;
|
|
173
|
+
maxBytes?: number;
|
|
148
174
|
now: Date;
|
|
149
175
|
}) => number;
|
package/dist/runs/run.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
2
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { userInfo } from "node:os";
|
|
4
|
+
import { join, sep } from "node:path";
|
|
4
5
|
import { createRecordLog, resolvePaths, writeSecurely } from "@wirecat/cli-base";
|
|
5
6
|
import { LOG_ATTRIBUTES } from "./log-attributes.js";
|
|
6
7
|
import { fromLogAttributes, logAttributes, snakeLogger } from "./log-keys.js";
|
|
7
8
|
export const KEEP_RUNS_FOR_DAYS = 30;
|
|
9
|
+
export const KEEP_SUCCESSFUL_RUNS_FOR_DAYS = 7;
|
|
10
|
+
export const RUNS_SIZE_LIMIT_MB = 200;
|
|
8
11
|
/** `<state dir>/runs` — never the cache directory: a cache is deleted to fix a cache, and a run cannot be rebuilt. */
|
|
9
12
|
export const runsDirFor = (app, env = process.env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "runs");
|
|
10
13
|
/**
|
|
@@ -16,12 +19,11 @@ export const runsDirFor = (app, env = process.env) => join(resolvePaths({ appNam
|
|
|
16
19
|
* events.jsonl one log record per event (cli-base's `LogRecord`), no ANSI ever
|
|
17
20
|
* ```
|
|
18
21
|
*
|
|
19
|
-
* `run.json` is written
|
|
22
|
+
* `run.json` is written as the directory opens, with `status: "running"`, and again by `finish`. A directory
|
|
20
23
|
* holding events and no metadata is a special case `runs list` would carry forever.
|
|
21
24
|
*
|
|
22
25
|
* Each write also appends the metadata to the day's `index.jsonl`, which `runs list` and `search runs`
|
|
23
|
-
* read instead of every `run.json`. It is only
|
|
24
|
-
* the slow way.
|
|
26
|
+
* read instead of every `run.json`. It is the only record of a `lazy` run that succeeded without events.
|
|
25
27
|
*/
|
|
26
28
|
export const startRun = (options) => {
|
|
27
29
|
const now = options.now ?? (() => new Date());
|
|
@@ -30,39 +32,13 @@ export const startRun = (options) => {
|
|
|
30
32
|
const dir = join(options.runsDir, day(startedAt), id);
|
|
31
33
|
// Only while something is being recorded: a tool that is not recording has no business walking
|
|
32
34
|
// that directory.
|
|
33
|
-
pruneRuns(options.runsDir, {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const file = snakeLogger(createRecordLog({
|
|
41
|
-
to: events,
|
|
42
|
-
runId: id,
|
|
43
|
-
command: options.command,
|
|
44
|
-
resource: { service: options.service, version: options.cliVersion },
|
|
45
|
-
allow: LOG_ATTRIBUTES,
|
|
46
|
-
...(options.traceId === undefined ? {} : { traceId: options.traceId }),
|
|
47
|
-
...(options.onError === undefined ? {} : { onError: options.onError }),
|
|
48
|
-
}));
|
|
49
|
-
const operations = new Set();
|
|
50
|
-
const codes = new Set();
|
|
51
|
-
const noted = (write) => (event, message) => {
|
|
52
|
-
const { operation, errorCode, error_code, code } = event;
|
|
53
|
-
if (typeof operation === "string")
|
|
54
|
-
operations.add(operation);
|
|
55
|
-
for (const value of [errorCode, error_code, code])
|
|
56
|
-
if (typeof value === "string")
|
|
57
|
-
codes.add(value);
|
|
58
|
-
write(event, message);
|
|
59
|
-
};
|
|
60
|
-
const logger = {
|
|
61
|
-
debug: noted(file.debug),
|
|
62
|
-
info: noted(file.info),
|
|
63
|
-
warn: noted(file.warn),
|
|
64
|
-
error: noted(file.error),
|
|
65
|
-
};
|
|
35
|
+
pruneRuns(options.runsDir, {
|
|
36
|
+
keepDays: options.keepDays ?? KEEP_RUNS_FOR_DAYS,
|
|
37
|
+
keepSuccessDays: options.keepSuccessDays ?? KEEP_SUCCESSFUL_RUNS_FOR_DAYS,
|
|
38
|
+
maxBytes: options.maxBytes ?? RUNS_SIZE_LIMIT_MB * 1024 * 1024,
|
|
39
|
+
now: startedAt,
|
|
40
|
+
});
|
|
41
|
+
refuseRealStateInTests(dir);
|
|
66
42
|
const metadata = {
|
|
67
43
|
runId: id,
|
|
68
44
|
command: options.command,
|
|
@@ -76,8 +52,46 @@ export const startRun = (options) => {
|
|
|
76
52
|
...(options.traceId === undefined ? {} : { traceId: options.traceId }),
|
|
77
53
|
...options.context,
|
|
78
54
|
};
|
|
79
|
-
|
|
55
|
+
let file;
|
|
56
|
+
const opened = () => {
|
|
57
|
+
if (file)
|
|
58
|
+
return file;
|
|
59
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
60
|
+
// Created here rather than by the logger, which opens with whatever the umask allows. The
|
|
61
|
+
// directory is already 0700, so this is the second lock rather than the first — but a log file
|
|
62
|
+
// that outlives its directory, or is copied out of it, should not be the one that is readable.
|
|
63
|
+
const events = join(dir, "events.jsonl");
|
|
64
|
+
writeFileSync(events, "", { flag: "a", mode: 0o600 });
|
|
65
|
+
file = snakeLogger(createRecordLog({
|
|
66
|
+
to: events,
|
|
67
|
+
runId: id,
|
|
68
|
+
command: options.command,
|
|
69
|
+
resource: { service: options.service, version: options.cliVersion },
|
|
70
|
+
allow: LOG_ATTRIBUTES,
|
|
71
|
+
...(options.traceId === undefined ? {} : { traceId: options.traceId }),
|
|
72
|
+
...(options.onError === undefined ? {} : { onError: options.onError }),
|
|
73
|
+
}));
|
|
74
|
+
writeRunFile(dir, metadata);
|
|
75
|
+
return file;
|
|
76
|
+
};
|
|
77
|
+
if (!options.lazy)
|
|
78
|
+
opened();
|
|
80
79
|
let finished = false;
|
|
80
|
+
const operations = new Set();
|
|
81
|
+
const codes = new Set();
|
|
82
|
+
const noted = (level) => (event, message) => {
|
|
83
|
+
const { operation, errorCode, error_code, code } = event;
|
|
84
|
+
if (typeof operation === "string")
|
|
85
|
+
operations.add(operation);
|
|
86
|
+
for (const value of [errorCode, error_code, code])
|
|
87
|
+
if (typeof value === "string")
|
|
88
|
+
codes.add(value);
|
|
89
|
+
// A connection a long-running server keeps can log after the run that opened it ended: that is not this run.
|
|
90
|
+
if (finished)
|
|
91
|
+
return;
|
|
92
|
+
opened()[level](event, message);
|
|
93
|
+
};
|
|
94
|
+
const logger = { debug: noted("debug"), info: noted("info"), warn: noted("warn"), error: noted("error") };
|
|
81
95
|
return {
|
|
82
96
|
id,
|
|
83
97
|
dir,
|
|
@@ -87,7 +101,7 @@ export const startRun = (options) => {
|
|
|
87
101
|
return;
|
|
88
102
|
finished = true;
|
|
89
103
|
const completedAt = now();
|
|
90
|
-
|
|
104
|
+
const final = {
|
|
91
105
|
...metadata,
|
|
92
106
|
...extra,
|
|
93
107
|
status,
|
|
@@ -95,10 +109,40 @@ export const startRun = (options) => {
|
|
|
95
109
|
durationMs: completedAt.getTime() - startedAt.getTime(),
|
|
96
110
|
operations: [...operations].sort(),
|
|
97
111
|
codes: [...codes].sort(),
|
|
98
|
-
}
|
|
112
|
+
};
|
|
113
|
+
if (file || status !== "success") {
|
|
114
|
+
opened();
|
|
115
|
+
writeRunFile(dir, final);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
mkdirSync(join(dir, ".."), { recursive: true, mode: 0o700 });
|
|
120
|
+
appendIndex(join(dir, ".."), runFileOf(final));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
options.onError?.(error);
|
|
124
|
+
}
|
|
99
125
|
},
|
|
100
126
|
};
|
|
101
127
|
};
|
|
128
|
+
/**
|
|
129
|
+
* The test sandbox moves HOME and the XDG folders, so a run that still resolves into the owner's own data folder is a
|
|
130
|
+
* fixture that bypassed it: thousands of test runs once landed beside the owner's real ones. The passwd entry's home
|
|
131
|
+
* is read because HOME is the very thing the sandbox changed.
|
|
132
|
+
*/
|
|
133
|
+
export const refuseRealStateInTests = (dir, env = process.env) => {
|
|
134
|
+
if (env.VITEST === undefined)
|
|
135
|
+
return;
|
|
136
|
+
let home;
|
|
137
|
+
try {
|
|
138
|
+
home = userInfo().homedir;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (dir.startsWith(join(home, ".local", "share") + sep))
|
|
144
|
+
throw new Error(`a test opened a run in the real data folder: ${dir}`);
|
|
145
|
+
};
|
|
102
146
|
export const runtime = () => {
|
|
103
147
|
const bun = process.versions.bun;
|
|
104
148
|
return bun ? `bun ${bun}` : `node ${process.versions.node}`;
|
|
@@ -113,16 +157,19 @@ export const newRunId = (startedAt, command) => {
|
|
|
113
157
|
return `${stamp}-${slug}-${randomUUID().slice(0, 6)}`;
|
|
114
158
|
};
|
|
115
159
|
const INDEX = "index.jsonl";
|
|
160
|
+
const appendIndex = (dayDir, written) => {
|
|
161
|
+
appendFileSync(join(dayDir, INDEX), `${JSON.stringify(written)}\n`, { mode: 0o600 });
|
|
162
|
+
};
|
|
116
163
|
const writeRunFile = (dir, metadata) => {
|
|
117
164
|
// Atomic, so a reader never sees half of one — and 0600, because it names a profile and a
|
|
118
165
|
// command even though it carries nothing of what was said.
|
|
119
166
|
const written = runFileOf(metadata);
|
|
120
167
|
writeSecurely(join(dir, "run.json"), `${JSON.stringify(written, null, 2)}\n`, 0o600);
|
|
121
168
|
try {
|
|
122
|
-
|
|
169
|
+
appendIndex(join(dir, ".."), written);
|
|
123
170
|
}
|
|
124
171
|
catch {
|
|
125
|
-
//
|
|
172
|
+
// A day without an index is read run by run.
|
|
126
173
|
}
|
|
127
174
|
};
|
|
128
175
|
const matchesListFilter = (run, filter) => (filter.runId === undefined || run.runId === filter.runId) &&
|
|
@@ -151,7 +198,7 @@ export const listRuns = (runsDir, { since, atLeast = Number.POSITIVE_INFINITY, f
|
|
|
151
198
|
* The index says what it holds; a run it does not name — written before there was an index, by an older
|
|
152
199
|
* process still running, or when the append failed — is read from its own `run.json`.
|
|
153
200
|
*/
|
|
154
|
-
const
|
|
201
|
+
const indexedRuns = (dayDir) => {
|
|
155
202
|
const latest = new Map();
|
|
156
203
|
let raw = "";
|
|
157
204
|
try {
|
|
@@ -170,8 +217,12 @@ const runsOfDay = (dayDir) => {
|
|
|
170
217
|
// A torn line from a killed process; that run's next line, or its run.json, still says it.
|
|
171
218
|
}
|
|
172
219
|
}
|
|
220
|
+
return latest;
|
|
221
|
+
};
|
|
222
|
+
const runsOfDay = (dayDir) => {
|
|
223
|
+
const latest = indexedRuns(dayDir);
|
|
173
224
|
for (const entry of safeReaddir(dayDir)) {
|
|
174
|
-
if (entry === INDEX || latest.has(entry))
|
|
225
|
+
if (entry === INDEX || entry.startsWith(".") || latest.has(entry))
|
|
175
226
|
continue;
|
|
176
227
|
const metadata = readRun(join(dayDir, entry));
|
|
177
228
|
if (metadata)
|
|
@@ -182,12 +233,19 @@ const runsOfDay = (dayDir) => {
|
|
|
182
233
|
export const findRun = (runsDir, id) => {
|
|
183
234
|
const stamped = /^(\d{4})(\d{2})(\d{2})T/.exec(id);
|
|
184
235
|
const likely = stamped ? [`${stamped[1]}-${stamped[2]}-${stamped[3]}`] : [];
|
|
185
|
-
|
|
236
|
+
const dates = [...new Set([...likely, ...days(runsDir)])];
|
|
237
|
+
for (const date of dates) {
|
|
186
238
|
const dir = join(runsDir, date, id);
|
|
187
239
|
const metadata = readRun(dir);
|
|
188
240
|
if (metadata)
|
|
189
241
|
return { dir, metadata };
|
|
190
242
|
}
|
|
243
|
+
// A success that logged no events is a line in its day's index and nothing else.
|
|
244
|
+
for (const date of dates) {
|
|
245
|
+
const metadata = indexedRuns(join(runsDir, date)).get(id);
|
|
246
|
+
if (metadata)
|
|
247
|
+
return { dir: join(runsDir, date, id), metadata };
|
|
248
|
+
}
|
|
191
249
|
return undefined;
|
|
192
250
|
};
|
|
193
251
|
/**
|
|
@@ -226,17 +284,23 @@ const eventOf = (line) => {
|
|
|
226
284
|
return { time, severity, ...logAttributes(attributes) };
|
|
227
285
|
};
|
|
228
286
|
/**
|
|
229
|
-
* Removes every day older than `keepDays`,
|
|
287
|
+
* Removes every day older than `keepDays`, then the successful runs of every day older than `keepSuccessDays`, and
|
|
288
|
+
* answers how many days went. Then, once a day, the folder is measured against `maxBytes`: successful runs go first,
|
|
289
|
+
* oldest day first, then whole days, until it fits.
|
|
230
290
|
*
|
|
231
|
-
* A
|
|
232
|
-
*
|
|
291
|
+
* A day at a time, by the directory name and the day's index, so no run has to be opened to decide — and a directory
|
|
292
|
+
* that is not a date is left alone rather than guessed at.
|
|
233
293
|
*/
|
|
234
|
-
export const pruneRuns = (runsDir, { keepDays, now }) => {
|
|
235
|
-
const cutoff = day(new Date(now.getTime() - keepDays *
|
|
294
|
+
export const pruneRuns = (runsDir, { keepDays, keepSuccessDays = keepDays, maxBytes, now, }) => {
|
|
295
|
+
const cutoff = day(new Date(now.getTime() - keepDays * DAY_MS));
|
|
296
|
+
const successCutoff = day(new Date(now.getTime() - Math.min(keepSuccessDays, keepDays) * DAY_MS));
|
|
236
297
|
let removed = 0;
|
|
237
298
|
for (const date of days(runsDir)) {
|
|
238
|
-
if (date >= cutoff)
|
|
299
|
+
if (date >= cutoff) {
|
|
300
|
+
if (date < successCutoff)
|
|
301
|
+
dropSuccesses(join(runsDir, date));
|
|
239
302
|
continue;
|
|
303
|
+
}
|
|
240
304
|
try {
|
|
241
305
|
rmSync(join(runsDir, date), { recursive: true, force: true });
|
|
242
306
|
removed += 1;
|
|
@@ -245,6 +309,91 @@ export const pruneRuns = (runsDir, { keepDays, now }) => {
|
|
|
245
309
|
// A day we cannot remove is not a reason to fail the command that was only starting.
|
|
246
310
|
}
|
|
247
311
|
}
|
|
312
|
+
if (maxBytes !== undefined)
|
|
313
|
+
removed += fitSize(runsDir, maxBytes, now);
|
|
314
|
+
return removed;
|
|
315
|
+
};
|
|
316
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
317
|
+
const SUCCESSES_DROPPED = ".successes-dropped";
|
|
318
|
+
const SIZE_CHECKED = ".size-checked";
|
|
319
|
+
/** A day's successful runs, out of its index and off the disk; once per day, marked by a file beside them. */
|
|
320
|
+
const dropSuccesses = (dayDir) => {
|
|
321
|
+
const marker = join(dayDir, SUCCESSES_DROPPED);
|
|
322
|
+
try {
|
|
323
|
+
statSync(marker);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
catch { }
|
|
327
|
+
try {
|
|
328
|
+
const kept = [];
|
|
329
|
+
for (const [id, metadata] of indexedRuns(dayDir)) {
|
|
330
|
+
if (metadata.status === "success")
|
|
331
|
+
rmSync(join(dayDir, id), { recursive: true, force: true });
|
|
332
|
+
else
|
|
333
|
+
kept.push(JSON.stringify(runFileOf(metadata)));
|
|
334
|
+
}
|
|
335
|
+
for (const entry of safeReaddir(dayDir)) {
|
|
336
|
+
if (entry === INDEX || entry.startsWith("."))
|
|
337
|
+
continue;
|
|
338
|
+
if (readRun(join(dayDir, entry))?.status === "success")
|
|
339
|
+
rmSync(join(dayDir, entry), { recursive: true, force: true });
|
|
340
|
+
}
|
|
341
|
+
writeSecurely(join(dayDir, INDEX), kept.map((line) => `${line}\n`).join(""), 0o600);
|
|
342
|
+
writeFileSync(marker, "", { mode: 0o600 });
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// Tried again at the next run's start.
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
const sizeOf = (path) => {
|
|
349
|
+
let total = 0;
|
|
350
|
+
for (const entry of safeReaddir(path)) {
|
|
351
|
+
const full = join(path, entry);
|
|
352
|
+
try {
|
|
353
|
+
const stat = statSync(full);
|
|
354
|
+
total += stat.isDirectory() ? sizeOf(full) : stat.size;
|
|
355
|
+
}
|
|
356
|
+
catch { }
|
|
357
|
+
}
|
|
358
|
+
return total;
|
|
359
|
+
};
|
|
360
|
+
/** Measuring walks every file, so it runs at most once a day; a burst can overshoot until the next day's check. */
|
|
361
|
+
const fitSize = (runsDir, maxBytes, now) => {
|
|
362
|
+
const stamp = join(runsDir, SIZE_CHECKED);
|
|
363
|
+
const today = day(now);
|
|
364
|
+
try {
|
|
365
|
+
if (readFileSync(stamp, "utf8").trim() === today)
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
368
|
+
catch { }
|
|
369
|
+
const dates = days(runsDir).sort();
|
|
370
|
+
const sizes = new Map(dates.map((date) => [date, sizeOf(join(runsDir, date))]));
|
|
371
|
+
let total = [...sizes.values()].reduce((sum, size) => sum + size, 0);
|
|
372
|
+
let removed = 0;
|
|
373
|
+
// Today is left to the age rule: a day marked done would keep the successes it gains later.
|
|
374
|
+
for (const date of dates) {
|
|
375
|
+
if (total <= maxBytes || date === today)
|
|
376
|
+
break;
|
|
377
|
+
dropSuccesses(join(runsDir, date));
|
|
378
|
+
const after = sizeOf(join(runsDir, date));
|
|
379
|
+
total -= (sizes.get(date) ?? 0) - after;
|
|
380
|
+
sizes.set(date, after);
|
|
381
|
+
}
|
|
382
|
+
for (const date of dates) {
|
|
383
|
+
if (total <= maxBytes || date === today)
|
|
384
|
+
break;
|
|
385
|
+
try {
|
|
386
|
+
rmSync(join(runsDir, date), { recursive: true, force: true });
|
|
387
|
+
total -= sizes.get(date) ?? 0;
|
|
388
|
+
removed += 1;
|
|
389
|
+
}
|
|
390
|
+
catch { }
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
mkdirSync(runsDir, { recursive: true, mode: 0o700 });
|
|
394
|
+
writeFileSync(stamp, today, { mode: 0o600 });
|
|
395
|
+
}
|
|
396
|
+
catch { }
|
|
248
397
|
return removed;
|
|
249
398
|
};
|
|
250
399
|
const day = (date) => date.toISOString().slice(0, 10);
|
package/dist/runs/scope.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { EventSink } from "./events.js";
|
|
1
2
|
export interface RunScope {
|
|
2
3
|
runId: string;
|
|
3
4
|
command: string;
|
|
@@ -7,6 +8,8 @@ export interface RunScope {
|
|
|
7
8
|
traceId?: string;
|
|
8
9
|
/** The profile the run is for, so a hint deep in a service can name the command with it. */
|
|
9
10
|
profile?: string;
|
|
11
|
+
/** The run's event sink, for a transport far below the command (a wire middleware) that is not handed one. */
|
|
12
|
+
events?: EventSink;
|
|
10
13
|
}
|
|
11
14
|
/**
|
|
12
15
|
* The run a log line written now belongs to, for a writer that is not handed one — the send journal,
|
|
@@ -14,6 +17,8 @@ export interface RunScope {
|
|
|
14
17
|
*/
|
|
15
18
|
export declare const currentRun: () => RunScope;
|
|
16
19
|
export declare const inRun: <T>(scope: RunScope, body: () => T) => T;
|
|
20
|
+
/** One event into the run under way, or nowhere: outside a run, or in a run that keeps nothing. */
|
|
21
|
+
export declare const runEvent: EventSink;
|
|
17
22
|
/**
|
|
18
23
|
* A command as a person types it: `max personal store migrate`. A hint without the profile fails when copied,
|
|
19
24
|
* since no profile is assumed; outside a run with a profile it stays `max store migrate`.
|
|
@@ -41,3 +46,10 @@ export declare const childTrace: () => {
|
|
|
41
46
|
traceId: string;
|
|
42
47
|
traceparent: string;
|
|
43
48
|
};
|
|
49
|
+
export interface Attempt {
|
|
50
|
+
attempt: number;
|
|
51
|
+
waitMs?: number;
|
|
52
|
+
}
|
|
53
|
+
/** Marks the calls made under it as one try of a retried call: a wire event without its own attempt takes this one. */
|
|
54
|
+
export declare const withAttempt: <T>(attempt: Attempt, work: () => T) => T;
|
|
55
|
+
export declare const currentAttempt: () => Attempt | undefined;
|
package/dist/runs/scope.js
CHANGED
|
@@ -21,6 +21,8 @@ export const currentRun = () => {
|
|
|
21
21
|
return processScope;
|
|
22
22
|
};
|
|
23
23
|
export const inRun = (scope, body) => scopes.run(scope, body);
|
|
24
|
+
/** One event into the run under way, or nowhere: outside a run, or in a run that keeps nothing. */
|
|
25
|
+
export const runEvent = (event) => scopes.getStore()?.events?.(event);
|
|
24
26
|
/**
|
|
25
27
|
* A command as a person types it: `max personal store migrate`. A hint without the profile fails when copied,
|
|
26
28
|
* since no profile is assumed; outside a run with a profile it stays `max store migrate`.
|
|
@@ -52,3 +54,7 @@ export const childTrace = () => {
|
|
|
52
54
|
const traceId = hex(16);
|
|
53
55
|
return { traceId, traceparent: `00-${traceId}-${hex(8)}-01` };
|
|
54
56
|
};
|
|
57
|
+
const attempts = new AsyncLocalStorage();
|
|
58
|
+
/** Marks the calls made under it as one try of a retried call: a wire event without its own attempt takes this one. */
|
|
59
|
+
export const withAttempt = (attempt, work) => attempts.run(attempt, work);
|
|
60
|
+
export const currentAttempt = () => attempts.getStore();
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { isCliFailure } from "../errors/failures.js";
|
|
3
|
+
import { withAttempt } from "../runs/scope.js";
|
|
3
4
|
/** Waits longer than this are not sat out: the run stops, and the next one resumes. */
|
|
4
5
|
const LONGEST_WAIT_MS = 5 * 60 * 1000;
|
|
5
6
|
/** Sits out a provider's "wait N seconds" when it is short, a few times; a long one ends the run. */
|
|
6
7
|
export const patiently = async (request, note, stop) => {
|
|
8
|
+
let waited;
|
|
7
9
|
for (let attempt = 1;; attempt += 1) {
|
|
8
10
|
stop.throwIfAborted();
|
|
9
11
|
try {
|
|
10
|
-
|
|
12
|
+
// A transport below logs this call as the retry it is, with the wait before it.
|
|
13
|
+
return await withAttempt({ attempt, ...(waited === undefined ? {} : { waitMs: waited }) }, request);
|
|
11
14
|
}
|
|
12
15
|
catch (error) {
|
|
13
16
|
const wait = isCliFailure(error) && error.code === "rate_limited" ? Number(error.details?.retry_after_ms) : NaN;
|
|
@@ -15,6 +18,7 @@ export const patiently = async (request, note, stop) => {
|
|
|
15
18
|
throw error;
|
|
16
19
|
note(`asked to wait ${Math.ceil(wait / 1000)} s — waiting, then going on`);
|
|
17
20
|
await sleep(wait, undefined, { signal: stop }).catch(() => { });
|
|
21
|
+
waited = wait;
|
|
18
22
|
if (stop.aborted)
|
|
19
23
|
throw error;
|
|
20
24
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wirecat/cli-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.467.0",
|
|
4
4
|
"description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Viacheslav Ptsarev",
|