@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.
Files changed (48) hide show
  1. package/README.md +194 -138
  2. package/dist/commands/agent.d.ts +8 -1
  3. package/dist/commands/agent.js +381 -56
  4. package/dist/commands/config.js +23 -9
  5. package/dist/commands/execute.d.ts +6 -1
  6. package/dist/commands/execute.js +105 -20
  7. package/dist/commands/init.d.ts +1 -0
  8. package/dist/commands/init.js +70 -63
  9. package/dist/commands/prepare.d.ts +1 -0
  10. package/dist/commands/prepare.js +14 -0
  11. package/dist/commands/repair.d.ts +1 -0
  12. package/dist/commands/repair.js +22 -0
  13. package/dist/commands/service.js +5 -4
  14. package/dist/commands/sessions.d.ts +24 -1
  15. package/dist/commands/sessions.js +165 -13
  16. package/dist/commands/skill.js +58 -32
  17. package/dist/commands/watch.d.ts +2 -1
  18. package/dist/commands/watch.js +130 -67
  19. package/dist/commands/workspace-registry.js +10 -1
  20. package/dist/display.d.ts +1 -0
  21. package/dist/display.js +24 -9
  22. package/dist/errors.d.ts +3 -0
  23. package/dist/errors.js +6 -0
  24. package/dist/exits.d.ts +12 -0
  25. package/dist/exits.js +33 -0
  26. package/dist/follow-log.d.ts +29 -0
  27. package/dist/follow-log.js +172 -0
  28. package/dist/fs-utils.d.ts +3 -0
  29. package/dist/fs-utils.js +26 -0
  30. package/dist/index.js +5 -1
  31. package/dist/migrate.js +2 -17
  32. package/dist/program.js +429 -177
  33. package/dist/session-row.d.ts +20 -0
  34. package/dist/session-row.js +21 -0
  35. package/dist/signals.d.ts +3 -0
  36. package/dist/signals.js +56 -0
  37. package/dist/templates/migent/SKILL.md +163 -56
  38. package/dist/templates/migent/references/config.md +52 -0
  39. package/dist/templates/migent/references/inspect.md +32 -0
  40. package/dist/templates/migent/references/schema-config.yaml +35 -0
  41. package/dist/templates/migent/references/schema-workflow.yaml +71 -0
  42. package/dist/templates/migent/references/sessions.md +65 -0
  43. package/dist/text.d.ts +4 -0
  44. package/dist/text.js +13 -0
  45. package/dist/version.d.ts +1 -0
  46. package/dist/version.js +3 -0
  47. package/dist/workspace.js +5 -12
  48. 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
- const payload = rows.map((row) => ({
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
+ }
@@ -1,20 +1,28 @@
1
1
  import { createHash } from "node:crypto";
2
- import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
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, content);
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
- const currentDigest = digest(current);
37
- if (installedDigest !== currentDigest && !options.force) {
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 content = templateContent();
42
- if (current === content && installedDigest === currentDigest && state.template_version === TEMPLATE_VERSION) {
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, content);
57
+ writeManagedSkill(skillDirectory, template);
47
58
  process.stdout.write(`Updated ${displayPath(skillDirectory, workspace)}\n`);
48
59
  return 0;
49
60
  }
50
- function templateContent() {
51
- const nextToModule = fileURLToPath(new URL("../templates/migent/SKILL.md", import.meta.url));
52
- return readFileSync(nextToModule, "utf8");
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 writeManagedSkill(skillDirectory, content) {
55
- writeFileSync(path.join(skillDirectory, "SKILL.md"), content, "utf8");
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: digest(content),
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 digest(content) {
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
- }
@@ -1,7 +1,8 @@
1
- export declare function watchSession(workspace: string, sessionId: string, options?: {
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>;
@@ -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, raiseCancellation, readExecutionFile, watchSnapshot, } from "@nodeint/migent-kernel";
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 watchSession(workspace, sessionId, options = {}) {
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
- if (once && untilTerminal)
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
- if (!isFile(eventsPath))
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
- new SessionStore(workspace).reapOrphanedExecutions(sessionId, "ignore");
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
- let followCursor = cursor;
37
- if (followCursor === null) {
38
- const snapshot = printSnapshot(sessionDirectory, executionId, jsonLines);
39
- followCursor = Number(snapshot.events_cursor ?? 0);
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 onSigint = () => {
43
- try {
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
- const read = readCompleteLines(fd, position, pending);
66
- position = read.position;
67
- pending = read.pending;
68
- for (const line of read.lines)
69
- emitWatchEvent(line, executionId, jsonLines);
70
- if (read.lines.length > 0)
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 = readCompleteLines(fd, position, pending);
78
- for (const line of drained.lines)
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
- closeSync(fd);
95
- process.off("SIGINT", onSigint);
96
- process.off("SIGTERM", onSigterm);
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: "0.1.0" } };
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;