@nodeint/migent 0.1.4 → 0.1.6
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/README.md +194 -138
- package/dist/commands/agent.d.ts +8 -1
- package/dist/commands/agent.js +381 -56
- package/dist/commands/config.js +23 -9
- package/dist/commands/execute.d.ts +6 -1
- package/dist/commands/execute.js +105 -20
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.js +70 -63
- package/dist/commands/prepare.d.ts +1 -0
- package/dist/commands/prepare.js +14 -0
- package/dist/commands/repair.d.ts +1 -0
- package/dist/commands/repair.js +22 -0
- package/dist/commands/service.js +5 -4
- package/dist/commands/sessions.d.ts +24 -1
- package/dist/commands/sessions.js +165 -13
- package/dist/commands/skill.js +58 -32
- package/dist/commands/watch.d.ts +2 -1
- package/dist/commands/watch.js +130 -67
- package/dist/commands/workspace-registry.js +10 -1
- package/dist/display.d.ts +1 -0
- package/dist/display.js +24 -9
- package/dist/errors.d.ts +3 -0
- package/dist/errors.js +6 -0
- package/dist/exits.d.ts +12 -0
- package/dist/exits.js +33 -0
- package/dist/follow-log.d.ts +29 -0
- package/dist/follow-log.js +172 -0
- package/dist/fs-utils.d.ts +3 -0
- package/dist/fs-utils.js +26 -0
- package/dist/index.js +5 -1
- package/dist/migrate.js +2 -17
- package/dist/program.js +429 -177
- package/dist/session-row.d.ts +20 -0
- package/dist/session-row.js +21 -0
- package/dist/signals.d.ts +3 -0
- package/dist/signals.js +56 -0
- package/dist/templates/migent/SKILL.md +163 -56
- package/dist/templates/migent/references/config.md +52 -0
- package/dist/templates/migent/references/inspect.md +32 -0
- package/dist/templates/migent/references/schema-config.yaml +35 -0
- package/dist/templates/migent/references/schema-workflow.yaml +71 -0
- package/dist/templates/migent/references/sessions.md +65 -0
- package/dist/text.d.ts +4 -0
- package/dist/text.js +13 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +3 -0
- package/dist/workspace.js +5 -12
- package/package.json +4 -4
|
@@ -1,20 +1,172 @@
|
|
|
1
|
-
import { listSessionRows } from "@nodeint/migent-kernel";
|
|
2
|
-
import { printSessions } from "../display.js";
|
|
1
|
+
import { listSealedMetrics, listSessionRows, readSealedMetrics, ConfigurationError } from "@nodeint/migent-kernel";
|
|
2
|
+
import { printError, printSessions } from "../display.js";
|
|
3
|
+
import { SESSION_COLUMN, sessionFields } from "../session-row.js";
|
|
4
|
+
import { optionalText } from "../text.js";
|
|
5
|
+
import { followSession } from "./watch.js";
|
|
6
|
+
const SESSION_STATUSES = new Set(["active", "prepared", "completed", "blocked", "cancelled", "corrupt"]);
|
|
3
7
|
export function listSessions(workspace, options = {}) {
|
|
4
|
-
const rows = listSessionRows(workspace);
|
|
8
|
+
const rows = selectSessionRows(listSessionRows(workspace), options);
|
|
9
|
+
if (options.latest) {
|
|
10
|
+
const row = rows[0];
|
|
11
|
+
if (options.asJson) {
|
|
12
|
+
process.stdout.write(`${JSON.stringify(row === undefined ? null : sessionJson(row))}\n`);
|
|
13
|
+
return 0;
|
|
14
|
+
}
|
|
15
|
+
printSessions(row === undefined ? [] : [row]);
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
5
18
|
if (options.asJson) {
|
|
6
|
-
|
|
7
|
-
session: row[1] ?? "",
|
|
8
|
-
workflow: row[2] ?? "",
|
|
9
|
-
status: row[3] ?? "",
|
|
10
|
-
execution: row[4] ?? "",
|
|
11
|
-
decision: row[5] ?? "",
|
|
12
|
-
outputs: row[6] ?? "",
|
|
13
|
-
task: row[7] ?? "",
|
|
14
|
-
}));
|
|
15
|
-
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
19
|
+
process.stdout.write(`${JSON.stringify(rows.map((row) => sessionJson(row)))}\n`);
|
|
16
20
|
return 0;
|
|
17
21
|
}
|
|
18
22
|
printSessions(rows);
|
|
19
23
|
return 0;
|
|
20
24
|
}
|
|
25
|
+
export function runSessionMetrics(workspace, session, options) {
|
|
26
|
+
if (!options.asJson) {
|
|
27
|
+
printError("session metrics requires --json.");
|
|
28
|
+
return 2;
|
|
29
|
+
}
|
|
30
|
+
const selection = parseListSelection({ ...options, latest: false });
|
|
31
|
+
const sessionId = optionalText(session);
|
|
32
|
+
if (sessionId !== null && hasListFilter(selection)) {
|
|
33
|
+
printError("List filters cannot be combined with a session.");
|
|
34
|
+
return 2;
|
|
35
|
+
}
|
|
36
|
+
if (sessionId === null) {
|
|
37
|
+
const rows = listSealedMetrics(workspace, {
|
|
38
|
+
workflow: selection.workflow ?? null,
|
|
39
|
+
status: selection.status ?? null,
|
|
40
|
+
limit: selection.limit ?? null,
|
|
41
|
+
});
|
|
42
|
+
for (const row of rows)
|
|
43
|
+
process.stdout.write(`${JSON.stringify(row)}\n`);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const sealed = readSealedMetrics(workspace, sessionId);
|
|
47
|
+
if (sealed.state === "missing") {
|
|
48
|
+
printError(`No metrics for session: ${sessionId}. Metrics are recorded when a workflow session completes.`);
|
|
49
|
+
return 2;
|
|
50
|
+
}
|
|
51
|
+
if (sealed.state === "corrupt") {
|
|
52
|
+
printError(`metrics.json for session ${sessionId} is not valid JSON.`);
|
|
53
|
+
return 2;
|
|
54
|
+
}
|
|
55
|
+
process.stdout.write(`${JSON.stringify(sealed.metrics)}\n`);
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
export async function runSessions(workspace, session, options) {
|
|
59
|
+
const selection = parseListSelection(options);
|
|
60
|
+
const sessionId = optionalText(session);
|
|
61
|
+
if (sessionId !== null && hasListFilter(selection)) {
|
|
62
|
+
printError("List filters cannot be combined with a session.");
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
if (sessionId === null)
|
|
66
|
+
return listOnly(workspace, options, selection);
|
|
67
|
+
if (options.until && !options.follow) {
|
|
68
|
+
printError("--until requires --follow.");
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
if (options.cursor !== undefined && !options.follow) {
|
|
72
|
+
printError("--cursor requires --follow.");
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
if (options.exitWhenDone && !options.follow) {
|
|
76
|
+
printError("--exit-when-done requires --follow.");
|
|
77
|
+
return 2;
|
|
78
|
+
}
|
|
79
|
+
if (options.until && options.execution === null) {
|
|
80
|
+
printError("--until requires --execution.");
|
|
81
|
+
return 2;
|
|
82
|
+
}
|
|
83
|
+
const cursor = parseCursor(options.cursor);
|
|
84
|
+
return followSession(workspace, sessionId, {
|
|
85
|
+
once: !options.follow,
|
|
86
|
+
executionId: options.execution,
|
|
87
|
+
untilTerminal: options.until,
|
|
88
|
+
jsonLines: options.asJson,
|
|
89
|
+
cursor,
|
|
90
|
+
exitWhenDone: options.exitWhenDone,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function listOnly(workspace, options, selection) {
|
|
94
|
+
if (options.follow) {
|
|
95
|
+
printError("--follow requires a session.");
|
|
96
|
+
return 2;
|
|
97
|
+
}
|
|
98
|
+
if (options.until) {
|
|
99
|
+
printError("--until requires --follow.");
|
|
100
|
+
return 2;
|
|
101
|
+
}
|
|
102
|
+
if (options.cursor !== undefined) {
|
|
103
|
+
printError("--cursor requires --follow.");
|
|
104
|
+
return 2;
|
|
105
|
+
}
|
|
106
|
+
if (options.exitWhenDone) {
|
|
107
|
+
printError("--exit-when-done requires --follow.");
|
|
108
|
+
return 2;
|
|
109
|
+
}
|
|
110
|
+
if (options.execution !== null) {
|
|
111
|
+
printError("--execution requires a session.");
|
|
112
|
+
return 2;
|
|
113
|
+
}
|
|
114
|
+
return listSessions(workspace, { ...selection, asJson: options.asJson });
|
|
115
|
+
}
|
|
116
|
+
function selectSessionRows(rows, options) {
|
|
117
|
+
let selected = [...rows];
|
|
118
|
+
if (options.workflow != null) {
|
|
119
|
+
selected = selected.filter((row) => row[SESSION_COLUMN.workflow] === options.workflow);
|
|
120
|
+
}
|
|
121
|
+
if (options.status != null) {
|
|
122
|
+
selected = selected.filter((row) => row[SESSION_COLUMN.status] === options.status);
|
|
123
|
+
}
|
|
124
|
+
const limit = options.latest ? 1 : (options.limit ?? null);
|
|
125
|
+
if (limit !== null)
|
|
126
|
+
return selected.slice(0, limit);
|
|
127
|
+
return selected;
|
|
128
|
+
}
|
|
129
|
+
function sessionJson(row) {
|
|
130
|
+
const fields = sessionFields(row);
|
|
131
|
+
return {
|
|
132
|
+
session: fields.session,
|
|
133
|
+
workflow: fields.workflow,
|
|
134
|
+
status: fields.status,
|
|
135
|
+
execution: fields.execution,
|
|
136
|
+
decision: fields.decision,
|
|
137
|
+
outputs: fields.outputs,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function hasListFilter(selection) {
|
|
141
|
+
return selection.latest === true || selection.workflow != null || selection.status != null || selection.limit != null;
|
|
142
|
+
}
|
|
143
|
+
function parseListSelection(options) {
|
|
144
|
+
const workflow = optionalText(options.workflow);
|
|
145
|
+
if (options.workflow !== null && workflow === null)
|
|
146
|
+
throw new ConfigurationError("--workflow is empty.");
|
|
147
|
+
const status = optionalText(options.status);
|
|
148
|
+
if (options.status !== null && status === null)
|
|
149
|
+
throw new ConfigurationError("--status is empty.");
|
|
150
|
+
if (status !== null && !SESSION_STATUSES.has(status)) {
|
|
151
|
+
throw new ConfigurationError("--status must be active, prepared, completed, blocked, cancelled, or corrupt.");
|
|
152
|
+
}
|
|
153
|
+
let limit = null;
|
|
154
|
+
if (options.limit !== null) {
|
|
155
|
+
if (!/^[0-9]+$/.test(options.limit))
|
|
156
|
+
throw new ConfigurationError("--limit must be an integer >= 1.");
|
|
157
|
+
limit = Number(options.limit);
|
|
158
|
+
if (limit < 1)
|
|
159
|
+
throw new ConfigurationError("--limit must be an integer >= 1.");
|
|
160
|
+
}
|
|
161
|
+
if (options.latest && limit !== null)
|
|
162
|
+
throw new ConfigurationError("--latest cannot be combined with --limit.");
|
|
163
|
+
return { workflow, status, limit, latest: options.latest };
|
|
164
|
+
}
|
|
165
|
+
function parseCursor(raw) {
|
|
166
|
+
if (raw === undefined)
|
|
167
|
+
return null;
|
|
168
|
+
const cursor = Number(raw);
|
|
169
|
+
if (!Number.isInteger(cursor) || cursor < 0)
|
|
170
|
+
throw new ConfigurationError("--cursor must be >= 0.");
|
|
171
|
+
return cursor;
|
|
172
|
+
}
|
package/dist/commands/skill.js
CHANGED
|
@@ -1,20 +1,28 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { printError } from "../display.js";
|
|
6
|
+
import { isDirectory, isFile } from "../fs-utils.js";
|
|
6
7
|
const SKILL_RELATIVE = path.join(".agents", "skills", "migent");
|
|
7
8
|
const STATE_FILENAME = ".migent-template.json";
|
|
8
9
|
const TEMPLATE_VERSION = 1;
|
|
10
|
+
const MANAGED = [
|
|
11
|
+
"SKILL.md",
|
|
12
|
+
"references/schema-config.yaml",
|
|
13
|
+
"references/schema-workflow.yaml",
|
|
14
|
+
"references/sessions.md",
|
|
15
|
+
"references/inspect.md",
|
|
16
|
+
"references/config.md",
|
|
17
|
+
];
|
|
9
18
|
export function installSkill(workspace) {
|
|
10
19
|
const skillDirectory = path.join(workspace, SKILL_RELATIVE);
|
|
11
20
|
if (existsSync(skillDirectory)) {
|
|
12
21
|
printError(`${displayPath(skillDirectory, workspace)} already exists; use \`migent skill update\`.`);
|
|
13
22
|
return 1;
|
|
14
23
|
}
|
|
15
|
-
const content = templateContent();
|
|
16
24
|
mkdirSync(skillDirectory, { recursive: true });
|
|
17
|
-
writeManagedSkill(skillDirectory,
|
|
25
|
+
writeManagedSkill(skillDirectory, readTemplate());
|
|
18
26
|
process.stdout.write(`Installed ${displayPath(skillDirectory, workspace)}\n`);
|
|
19
27
|
return 0;
|
|
20
28
|
}
|
|
@@ -31,32 +39,56 @@ export function updateSkill(workspace, options = {}) {
|
|
|
31
39
|
printError("Existing Migent skill is not managed by Migent; move it aside and run `migent skill install`.");
|
|
32
40
|
return 1;
|
|
33
41
|
}
|
|
34
|
-
const current = readFileSync(skillPath, "utf8");
|
|
35
42
|
const installedDigest = state.content_sha256;
|
|
36
|
-
|
|
37
|
-
|
|
43
|
+
if (typeof installedDigest !== "string") {
|
|
44
|
+
printError("Existing Migent skill is not managed by Migent; move it aside and run `migent skill install`.");
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
const installed = readInstalled(skillDirectory);
|
|
48
|
+
if (hasLocalChanges(installed, installedDigest) && !options.force) {
|
|
38
49
|
printError("Migent skill has local changes; rerun with `migent skill update --force` to replace them.");
|
|
39
50
|
return 1;
|
|
40
51
|
}
|
|
41
|
-
const
|
|
42
|
-
if (
|
|
52
|
+
const template = readTemplate();
|
|
53
|
+
if (bundleDigest(installed) === bundleDigest(template) && state.template_version === TEMPLATE_VERSION) {
|
|
43
54
|
process.stdout.write(`Already up to date: ${displayPath(skillDirectory, workspace)}\n`);
|
|
44
55
|
return 0;
|
|
45
56
|
}
|
|
46
|
-
writeManagedSkill(skillDirectory,
|
|
57
|
+
writeManagedSkill(skillDirectory, template);
|
|
47
58
|
process.stdout.write(`Updated ${displayPath(skillDirectory, workspace)}\n`);
|
|
48
59
|
return 0;
|
|
49
60
|
}
|
|
50
|
-
function
|
|
51
|
-
const
|
|
52
|
-
return readFileSync(
|
|
61
|
+
function readTemplate() {
|
|
62
|
+
const root = fileURLToPath(new URL("../templates/migent/", import.meta.url));
|
|
63
|
+
return MANAGED.map((name) => [name, readFileSync(path.join(root, name), "utf8")]);
|
|
53
64
|
}
|
|
54
|
-
function
|
|
55
|
-
|
|
65
|
+
function readInstalled(skillDirectory) {
|
|
66
|
+
const entries = [];
|
|
67
|
+
for (const name of MANAGED) {
|
|
68
|
+
const filePath = path.join(skillDirectory, name);
|
|
69
|
+
if (!isFile(filePath))
|
|
70
|
+
continue;
|
|
71
|
+
entries.push([name, readFileSync(filePath, "utf8")]);
|
|
72
|
+
}
|
|
73
|
+
return entries;
|
|
74
|
+
}
|
|
75
|
+
function hasLocalChanges(installed, installedDigest) {
|
|
76
|
+
if (bundleDigest(installed) === installedDigest)
|
|
77
|
+
return false;
|
|
78
|
+
const skill = installed.find(([name]) => name === "SKILL.md");
|
|
79
|
+
const references = installed.filter(([name]) => name !== "SKILL.md");
|
|
80
|
+
return !(skill !== undefined && references.length === 0 && skillOnlyDigest(skill[1]) === installedDigest);
|
|
81
|
+
}
|
|
82
|
+
function writeManagedSkill(skillDirectory, entries) {
|
|
83
|
+
for (const [name, content] of entries) {
|
|
84
|
+
const filePath = path.join(skillDirectory, name);
|
|
85
|
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
|
86
|
+
writeFileSync(filePath, content, "utf8");
|
|
87
|
+
}
|
|
56
88
|
const state = {
|
|
57
89
|
schema_version: 1,
|
|
58
90
|
template_version: TEMPLATE_VERSION,
|
|
59
|
-
content_sha256:
|
|
91
|
+
content_sha256: bundleDigest(entries),
|
|
60
92
|
};
|
|
61
93
|
writeFileSync(path.join(skillDirectory, STATE_FILENAME), `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
|
62
94
|
}
|
|
@@ -71,7 +103,17 @@ function readState(filePath) {
|
|
|
71
103
|
return null;
|
|
72
104
|
}
|
|
73
105
|
}
|
|
74
|
-
function
|
|
106
|
+
function bundleDigest(entries) {
|
|
107
|
+
const hash = createHash("sha256");
|
|
108
|
+
for (const [name, content] of entries) {
|
|
109
|
+
hash.update(name);
|
|
110
|
+
hash.update("\0");
|
|
111
|
+
hash.update(content);
|
|
112
|
+
hash.update("\0");
|
|
113
|
+
}
|
|
114
|
+
return hash.digest("hex");
|
|
115
|
+
}
|
|
116
|
+
function skillOnlyDigest(content) {
|
|
75
117
|
return createHash("sha256").update(content, "utf8").digest("hex");
|
|
76
118
|
}
|
|
77
119
|
function displayPath(filePath, workspace) {
|
|
@@ -80,19 +122,3 @@ function displayPath(filePath, workspace) {
|
|
|
80
122
|
function isRecord(value) {
|
|
81
123
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
82
124
|
}
|
|
83
|
-
function isFile(filePath) {
|
|
84
|
-
try {
|
|
85
|
-
return statSync(filePath).isFile();
|
|
86
|
-
}
|
|
87
|
-
catch {
|
|
88
|
-
return false;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
function isDirectory(filePath) {
|
|
92
|
-
try {
|
|
93
|
-
return statSync(filePath).isDirectory();
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return false;
|
|
97
|
-
}
|
|
98
|
-
}
|
package/dist/commands/watch.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export declare function
|
|
1
|
+
export declare function followSession(workspace: string, sessionId: string, options?: {
|
|
2
2
|
once?: boolean;
|
|
3
3
|
executionId?: string | null;
|
|
4
4
|
untilTerminal?: boolean;
|
|
5
5
|
jsonLines?: boolean;
|
|
6
6
|
cursor?: number | null;
|
|
7
|
+
exitWhenDone?: boolean;
|
|
7
8
|
}): Promise<number>;
|
package/dist/commands/watch.js
CHANGED
|
@@ -1,27 +1,43 @@
|
|
|
1
|
-
import { openSync, readSync, closeSync, statSync } from "node:fs";
|
|
1
|
+
import { openSync, readFileSync, readSync, closeSync, statSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { CancellationRequested, ConfigurationError, SessionStore, log,
|
|
3
|
+
import { CancellationRequested, ConfigurationError, corruptStatusMessage, SessionLock, SessionStore, log, readStatusFileState, readExecutionFile, statusDriftMessage, watchSnapshot, } from "@nodeint/migent-kernel";
|
|
4
4
|
import { printNotice, printWatchEvent, printWatchSnapshot } from "../display.js";
|
|
5
|
+
import { EXIT_BLOCKED, EXIT_CANCELLED, EXIT_OK } from "../exits.js";
|
|
6
|
+
import { compareFollowLines, includeStreamLine, listStreamFiles, streamFollowStart, } from "../follow-log.js";
|
|
7
|
+
import { isFile } from "../fs-utils.js";
|
|
8
|
+
import { bindCancellationSignals, delay, throwIfCliCancelled } from "../signals.js";
|
|
9
|
+
const SESSION_DONE_EXITS = {
|
|
10
|
+
completed: EXIT_OK,
|
|
11
|
+
blocked: EXIT_BLOCKED,
|
|
12
|
+
cancelled: EXIT_CANCELLED,
|
|
13
|
+
};
|
|
5
14
|
const TERMINAL_EXECUTION_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
|
6
|
-
export async function
|
|
15
|
+
export async function followSession(workspace, sessionId, options = {}) {
|
|
7
16
|
const once = options.once ?? false;
|
|
8
17
|
const untilTerminal = options.untilTerminal ?? false;
|
|
9
18
|
const jsonLines = options.jsonLines ?? false;
|
|
10
19
|
const executionId = options.executionId ?? null;
|
|
11
20
|
const cursor = options.cursor === undefined ? null : options.cursor;
|
|
12
|
-
|
|
13
|
-
throw new ConfigurationError("--once cannot be combined with --until.");
|
|
14
|
-
if (once && cursor !== null)
|
|
15
|
-
throw new ConfigurationError("--once cannot be combined with --cursor.");
|
|
16
|
-
if (untilTerminal && !executionId)
|
|
17
|
-
throw new ConfigurationError("--until requires --execution.");
|
|
18
|
-
if (cursor !== null && cursor < 0)
|
|
19
|
-
throw new ConfigurationError("--cursor must be >= 0.");
|
|
21
|
+
const exitWhenDone = options.exitWhenDone === true;
|
|
20
22
|
const sessionDirectory = path.join(workspace, ".migent", "sessions", sessionId);
|
|
21
23
|
const eventsPath = path.join(sessionDirectory, "events.jsonl");
|
|
22
|
-
|
|
24
|
+
const statusPath = path.join(sessionDirectory, "status.json");
|
|
25
|
+
if (readStatusFileState(statusPath).state === "corrupt") {
|
|
26
|
+
throw new ConfigurationError(corruptStatusMessage(statusPath));
|
|
27
|
+
}
|
|
28
|
+
if (!isFile(eventsPath) && !(once && isFile(path.join(sessionDirectory, "status.json")))) {
|
|
23
29
|
throw new ConfigurationError(`No events found for session: ${sessionId}`);
|
|
24
|
-
|
|
30
|
+
}
|
|
31
|
+
// Reaping writes, so it needs the session lock. A live holder keeps the session; follow then tails read-only.
|
|
32
|
+
const reapLock = SessionLock.tryAcquire(sessionDirectory);
|
|
33
|
+
if (reapLock !== null) {
|
|
34
|
+
try {
|
|
35
|
+
new SessionStore(workspace).reapOrphanedExecutions(sessionId, "ignore");
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
reapLock.release();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
25
41
|
let executionPath = null;
|
|
26
42
|
if (executionId !== null) {
|
|
27
43
|
const resolved = path.join(sessionDirectory, "executions", executionId, "execution.json");
|
|
@@ -30,56 +46,54 @@ export async function watchSession(workspace, sessionId, options = {}) {
|
|
|
30
46
|
throw new ConfigurationError(`No execution found: ${executionId}`);
|
|
31
47
|
}
|
|
32
48
|
if (once) {
|
|
49
|
+
const drift = statusDriftMessage(workspace, sessionId);
|
|
50
|
+
if (drift)
|
|
51
|
+
printNotice(drift);
|
|
33
52
|
printSnapshot(sessionDirectory, executionId, jsonLines);
|
|
34
53
|
return 0;
|
|
35
54
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
55
|
+
const tailOnly = cursor === null;
|
|
56
|
+
let followCursor = cursor ?? 0;
|
|
57
|
+
let streamOffsets = null;
|
|
58
|
+
if (cursor === null) {
|
|
59
|
+
// Snapshot and the notice can block. Capture offsets first so lines written then are still tailed.
|
|
60
|
+
streamOffsets = new Map(listStreamFiles(sessionDirectory).map((stream) => [stream, fileSize(stream)]));
|
|
61
|
+
followCursor = fileSize(eventsPath);
|
|
62
|
+
printSnapshot(sessionDirectory, executionId, jsonLines, followCursor);
|
|
40
63
|
}
|
|
41
64
|
printNotice(`Watching session ${sessionId}.`);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
raiseCancellation(2);
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
// The command loop observes the pending cancellation.
|
|
48
|
-
}
|
|
49
|
-
};
|
|
50
|
-
const onSigterm = () => {
|
|
51
|
-
try {
|
|
52
|
-
raiseCancellation(15);
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// The command loop observes the pending cancellation.
|
|
56
|
-
}
|
|
57
|
-
};
|
|
58
|
-
process.on("SIGINT", onSigint);
|
|
59
|
-
process.on("SIGTERM", onSigterm);
|
|
60
|
-
const fd = openSync(eventsPath, "r");
|
|
65
|
+
const unbind = bindCancellationSignals();
|
|
66
|
+
const logs = openFollowLogs(sessionDirectory, eventsPath, followCursor, tailOnly, streamOffsets);
|
|
61
67
|
try {
|
|
62
|
-
let position = followCursor;
|
|
63
|
-
let pending = "";
|
|
64
68
|
for (;;) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
for (const
|
|
69
|
-
emitWatchEvent(line, executionId, jsonLines);
|
|
70
|
-
if (
|
|
69
|
+
throwIfCliCancelled();
|
|
70
|
+
discoverStreams(sessionDirectory, logs);
|
|
71
|
+
const batch = readBatch(logs);
|
|
72
|
+
for (const item of batch)
|
|
73
|
+
emitWatchEvent(item.line, executionId, jsonLines);
|
|
74
|
+
if (batch.length > 0)
|
|
71
75
|
continue;
|
|
72
76
|
if (untilTerminal) {
|
|
73
77
|
if (executionPath === null)
|
|
74
78
|
throw new ConfigurationError("--until requires --execution.");
|
|
75
79
|
const record = readExecutionFile(executionPath);
|
|
76
80
|
if (record !== null && TERMINAL_EXECUTION_STATUSES.has(String(record.status))) {
|
|
77
|
-
const drained =
|
|
78
|
-
for (const
|
|
79
|
-
emitWatchEvent(line, executionId, jsonLines);
|
|
81
|
+
const drained = readBatch(logs);
|
|
82
|
+
for (const item of drained)
|
|
83
|
+
emitWatchEvent(item.line, executionId, jsonLines);
|
|
80
84
|
return untilTerminalExitCode(record);
|
|
81
85
|
}
|
|
82
86
|
}
|
|
87
|
+
if (exitWhenDone) {
|
|
88
|
+
const status = String(watchSnapshot(sessionDirectory, null).status ?? "");
|
|
89
|
+
const done = SESSION_DONE_EXITS[status];
|
|
90
|
+
if (done !== undefined) {
|
|
91
|
+
const drained = readBatch(logs);
|
|
92
|
+
for (const item of drained)
|
|
93
|
+
emitWatchEvent(item.line, executionId, jsonLines);
|
|
94
|
+
return done;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
83
97
|
await delay(250);
|
|
84
98
|
}
|
|
85
99
|
}
|
|
@@ -91,19 +105,26 @@ export async function watchSession(workspace, sessionId, options = {}) {
|
|
|
91
105
|
throw error;
|
|
92
106
|
}
|
|
93
107
|
finally {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
for (const log of logs)
|
|
109
|
+
closeSync(log.fd);
|
|
110
|
+
unbind();
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
|
-
function printSnapshot(sessionDirectory, executionId, jsonLines) {
|
|
113
|
+
function printSnapshot(sessionDirectory, executionId, jsonLines, eventsCursor) {
|
|
100
114
|
const snapshot = watchSnapshot(sessionDirectory, executionId);
|
|
115
|
+
if (eventsCursor !== undefined)
|
|
116
|
+
snapshot.events_cursor = eventsCursor;
|
|
101
117
|
if (jsonLines)
|
|
102
|
-
process.stdout.write(`${JSON.stringify(snapshot)}\n`);
|
|
118
|
+
process.stdout.write(`${JSON.stringify(snapshotJson(snapshot))}\n`);
|
|
103
119
|
else
|
|
104
120
|
printWatchSnapshot(snapshot);
|
|
105
121
|
return snapshot;
|
|
106
122
|
}
|
|
123
|
+
function snapshotJson(snapshot) {
|
|
124
|
+
const body = { ...snapshot };
|
|
125
|
+
delete body.task;
|
|
126
|
+
return body;
|
|
127
|
+
}
|
|
107
128
|
function emitWatchEvent(line, executionId, jsonLines) {
|
|
108
129
|
let event;
|
|
109
130
|
try {
|
|
@@ -129,10 +150,65 @@ function untilTerminalExitCode(record) {
|
|
|
129
150
|
return 0;
|
|
130
151
|
return 1;
|
|
131
152
|
}
|
|
132
|
-
if (record.status === "failed" || record.status === "cancelled")
|
|
133
|
-
return 1;
|
|
134
153
|
return 1;
|
|
135
154
|
}
|
|
155
|
+
function openFollowLogs(sessionDirectory, eventsPath, cursor, tailOnly, streamOffsets) {
|
|
156
|
+
const eventsText = !tailOnly && cursor > 0 ? readText(eventsPath) : "";
|
|
157
|
+
const logs = [openLog(eventsPath, cursor, null, "events")];
|
|
158
|
+
for (const stream of listStreamFiles(sessionDirectory)) {
|
|
159
|
+
const start = tailOnly
|
|
160
|
+
? { offset: tailOffset(stream, streamOffsets), minTimestampMs: null }
|
|
161
|
+
: streamFollowStart(cursor, eventsText, fileSize(stream));
|
|
162
|
+
logs.push(openLog(stream, start.offset, start.minTimestampMs, "stream"));
|
|
163
|
+
}
|
|
164
|
+
return logs;
|
|
165
|
+
}
|
|
166
|
+
function tailOffset(stream, streamOffsets) {
|
|
167
|
+
if (streamOffsets === null)
|
|
168
|
+
return fileSize(stream);
|
|
169
|
+
return streamOffsets.get(stream) ?? 0;
|
|
170
|
+
}
|
|
171
|
+
function discoverStreams(sessionDirectory, logs) {
|
|
172
|
+
const known = new Set(logs.map((log) => log.path));
|
|
173
|
+
for (const stream of listStreamFiles(sessionDirectory)) {
|
|
174
|
+
if (known.has(stream))
|
|
175
|
+
continue;
|
|
176
|
+
logs.push(openLog(stream, 0, null, "stream"));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function readBatch(logs) {
|
|
180
|
+
const batch = [];
|
|
181
|
+
for (const log of logs) {
|
|
182
|
+
const read = readCompleteLines(log.fd, log.offset, log.pending);
|
|
183
|
+
log.offset = read.position;
|
|
184
|
+
log.pending = read.pending;
|
|
185
|
+
// A later poll can finish a torn line that is older than the cursor anchor.
|
|
186
|
+
const lines = read.lines.filter((line) => includeStreamLine(line, log.minTimestampMs));
|
|
187
|
+
for (const line of lines)
|
|
188
|
+
batch.push({ line, source: log.source, path: log.path });
|
|
189
|
+
}
|
|
190
|
+
batch.sort(compareFollowLines);
|
|
191
|
+
return batch;
|
|
192
|
+
}
|
|
193
|
+
function openLog(filePath, offset, minTimestampMs, source) {
|
|
194
|
+
return { path: filePath, fd: openSync(filePath, "r"), offset, pending: "", minTimestampMs, source };
|
|
195
|
+
}
|
|
196
|
+
function fileSize(filePath) {
|
|
197
|
+
try {
|
|
198
|
+
return statSync(filePath).size;
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function readText(filePath) {
|
|
205
|
+
try {
|
|
206
|
+
return readFileSync(filePath, "utf8");
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return "";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
136
212
|
function readCompleteLines(fd, position, pending) {
|
|
137
213
|
const chunks = [];
|
|
138
214
|
const buffer = Buffer.alloc(64 * 1024);
|
|
@@ -151,19 +227,6 @@ function readCompleteLines(fd, position, pending) {
|
|
|
151
227
|
const rest = pieces.pop() ?? "";
|
|
152
228
|
return { lines: pieces, pending: rest, position: offset };
|
|
153
229
|
}
|
|
154
|
-
function delay(ms) {
|
|
155
|
-
return new Promise((resolve) => {
|
|
156
|
-
setTimeout(resolve, ms);
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
function isFile(filePath) {
|
|
160
|
-
try {
|
|
161
|
-
return statSync(filePath).isFile();
|
|
162
|
-
}
|
|
163
|
-
catch {
|
|
164
|
-
return false;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
230
|
function isRecord(value) {
|
|
168
231
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
169
232
|
}
|
|
@@ -2,6 +2,7 @@ import { realpathSync } from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { ServiceError, callService, endpointFromHome, ensureRegistryHome, startDetached } from "@nodeint/migent-registry";
|
|
4
4
|
import { printError } from "../display.js";
|
|
5
|
+
import { PACKAGE_VERSION } from "../version.js";
|
|
5
6
|
import { findWorkspace, invocationCwdPath } from "../workspace.js";
|
|
6
7
|
export const workspaceApi = {
|
|
7
8
|
startDetached,
|
|
@@ -25,7 +26,7 @@ export async function runRegister(workspacePath, name) {
|
|
|
25
26
|
}
|
|
26
27
|
throw error;
|
|
27
28
|
}
|
|
28
|
-
const body = { path: root, tool: { name: "migent", version:
|
|
29
|
+
const body = { path: root, tool: { name: "migent", version: PACKAGE_VERSION } };
|
|
29
30
|
if (name !== null)
|
|
30
31
|
body.name = name;
|
|
31
32
|
if (!path.isAbsolute(String(body.path))) {
|
|
@@ -88,6 +89,14 @@ async function finish(method, requestPath, endpoint, body) {
|
|
|
88
89
|
}
|
|
89
90
|
if (status === 200 || status === 201) {
|
|
90
91
|
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
92
|
+
if (method === "POST") {
|
|
93
|
+
const id = typeof payload.id === "string" && payload.id !== "" ? payload.id : "workspace";
|
|
94
|
+
process.stderr.write(`Registered ${id}.\n`);
|
|
95
|
+
}
|
|
96
|
+
else if (method === "DELETE") {
|
|
97
|
+
const id = requestPath.split("/").pop() ?? "workspace";
|
|
98
|
+
process.stderr.write(`Removed ${id}.\n`);
|
|
99
|
+
}
|
|
91
100
|
return 0;
|
|
92
101
|
}
|
|
93
102
|
return statusExit(status, payload);
|
package/dist/display.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Check } from "@nodeint/migent-kernel";
|
|
2
2
|
export declare function printError(message: string): void;
|
|
3
3
|
export declare function printNotice(message: string): void;
|
|
4
|
+
export declare function printResumeCommand(sessionId: string): void;
|
|
4
5
|
export declare function printCreated(paths: readonly string[], nextCommand: string): void;
|
|
5
6
|
export declare function printAlreadyInitialized(): void;
|
|
6
7
|
export declare function printInitConflicts(paths: readonly string[]): void;
|