@moor-sh/mcp 0.27.1 → 0.27.2
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 +101 -10
- package/bin/moor-mcp.js +2 -0
- package/dist/index.js +2157 -0
- package/package.json +8 -4
- package/src/index.ts +0 -3100
- package/src/tail-utf8.test.ts +0 -69
- package/src/tail-utf8.ts +0 -25
- package/src/update-audit-render.test.ts +0 -171
- package/src/update-audit-render.ts +0 -94
package/src/tail-utf8.test.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { tailUtf8 } from "./tail-utf8";
|
|
3
|
-
|
|
4
|
-
describe("tailUtf8", () => {
|
|
5
|
-
test("returns the string unchanged when under maxBytes", () => {
|
|
6
|
-
const r = tailUtf8("hello", 100);
|
|
7
|
-
expect(r.tail).toBe("hello");
|
|
8
|
-
expect(r.storedBytes).toBe(5);
|
|
9
|
-
expect(r.trimmed).toBe(false);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test("returns the last maxBytes bytes for ASCII input", () => {
|
|
13
|
-
const r = tailUtf8("0123456789", 4);
|
|
14
|
-
expect(r.tail).toBe("6789");
|
|
15
|
-
expect(r.storedBytes).toBe(10);
|
|
16
|
-
expect(r.trimmed).toBe(true);
|
|
17
|
-
});
|
|
18
|
-
|
|
19
|
-
test("returns an empty string when maxBytes is 0", () => {
|
|
20
|
-
const r = tailUtf8("hello", 0);
|
|
21
|
-
expect(r.tail).toBe("");
|
|
22
|
-
expect(r.storedBytes).toBe(5);
|
|
23
|
-
expect(r.trimmed).toBe(true);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("preserves complete trailing multi-byte codepoints", () => {
|
|
27
|
-
// "café" = c(1) a(1) f(1) é(2) = 5 bytes. Cap at 4 → trim "c", landing
|
|
28
|
-
// on "a" boundary; result "afé" (4 bytes).
|
|
29
|
-
const r = tailUtf8("café", 4);
|
|
30
|
-
expect(r.tail).toBe("afé");
|
|
31
|
-
expect(r.storedBytes).toBe(5);
|
|
32
|
-
expect(r.trimmed).toBe(true);
|
|
33
|
-
expect(r.tail).not.toContain("\ufffd");
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("aligns to a codepoint boundary when the cut falls inside a 2-byte sequence", () => {
|
|
37
|
-
// "héllo" = h(1) é(2) l(1) l(1) o(1) = 6 bytes. Cap at 4 → start at
|
|
38
|
-
// byte 2, which is the continuation of é. Align forward to "llo" (3 bytes).
|
|
39
|
-
const r = tailUtf8("héllo", 4);
|
|
40
|
-
expect(r.tail).toBe("llo");
|
|
41
|
-
expect(r.storedBytes).toBe(6);
|
|
42
|
-
expect(r.tail).not.toContain("\ufffd");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
test("aligns to a codepoint boundary inside a 3-byte sequence", () => {
|
|
46
|
-
// "€€€" = 9 bytes (each € = 3 bytes). Cap at 4 → start at byte 5, which
|
|
47
|
-
// is a continuation. Align forward to start of last € → "€" (3 bytes).
|
|
48
|
-
const r = tailUtf8("€€€", 4);
|
|
49
|
-
expect(r.tail).toBe("€");
|
|
50
|
-
expect(r.storedBytes).toBe(9);
|
|
51
|
-
expect(r.tail).not.toContain("\ufffd");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("aligns inside a 4-byte sequence (surrogate pair codepoint)", () => {
|
|
55
|
-
// "🌍🌍" = 8 bytes (each emoji = 4 bytes). Cap at 5 → start at byte 3,
|
|
56
|
-
// continuation. Align forward to start of last emoji → "🌍" (4 bytes).
|
|
57
|
-
const r = tailUtf8("🌍🌍", 5);
|
|
58
|
-
expect(r.tail).toBe("🌍");
|
|
59
|
-
expect(r.storedBytes).toBe(8);
|
|
60
|
-
expect(r.tail).not.toContain("\ufffd");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("reports storedBytes as the original UTF-8 byte length, not JS char length", () => {
|
|
64
|
-
// "🌍" is 1 codepoint but 4 UTF-8 bytes (and 2 UTF-16 code units in JS).
|
|
65
|
-
const r = tailUtf8("🌍", 100);
|
|
66
|
-
expect(r.storedBytes).toBe(4);
|
|
67
|
-
expect(r.tail.length).toBe(2); // JS string length counts UTF-16 code units
|
|
68
|
-
});
|
|
69
|
-
});
|
package/src/tail-utf8.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
// Byte-aware UTF-8 tail-trim used by moor_exec_status (#44). The MCP tool
|
|
2
|
-
// can't return the full API tail (up to 64 KiB per stream) inline because
|
|
3
|
-
// large responses blow agent token budgets. Caller picks how many bytes per
|
|
4
|
-
// stream they want; the trim is UTF-8 safe — the result never starts mid-
|
|
5
|
-
// codepoint, which would cause TextDecoder to emit U+FFFD at the head.
|
|
6
|
-
|
|
7
|
-
export function tailUtf8(
|
|
8
|
-
s: string,
|
|
9
|
-
maxBytes: number,
|
|
10
|
-
): { tail: string; storedBytes: number; trimmed: boolean } {
|
|
11
|
-
const bytes = new TextEncoder().encode(s);
|
|
12
|
-
const storedBytes = bytes.length;
|
|
13
|
-
if (storedBytes <= maxBytes) return { tail: s, storedBytes, trimmed: false };
|
|
14
|
-
if (maxBytes === 0) return { tail: "", storedBytes, trimmed: true };
|
|
15
|
-
// Start at the offset that leaves maxBytes bytes; walk forward past any
|
|
16
|
-
// UTF-8 continuation bytes (10xxxxxx) so the decoded tail begins on a
|
|
17
|
-
// codepoint boundary.
|
|
18
|
-
let start = storedBytes - maxBytes;
|
|
19
|
-
while (start < storedBytes && (bytes[start] & 0xc0) === 0x80) start++;
|
|
20
|
-
return {
|
|
21
|
-
tail: new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(start)),
|
|
22
|
-
storedBytes,
|
|
23
|
-
trimmed: true,
|
|
24
|
-
};
|
|
25
|
-
}
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
// Tests for the moor_update_audit renderer. Pure functions — no
|
|
2
|
-
// network, no DB. Lives next to its consumer so the formatting
|
|
3
|
-
// operators actually see has direct test coverage.
|
|
4
|
-
|
|
5
|
-
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
7
|
-
DEFAULT_LOG_TAIL_BYTES,
|
|
8
|
-
fmtDuration,
|
|
9
|
-
MAX_LOG_TAIL_BYTES,
|
|
10
|
-
renderAuditList,
|
|
11
|
-
renderAuditRow,
|
|
12
|
-
shortDigest,
|
|
13
|
-
tailLog,
|
|
14
|
-
type UpdateAuditApiRow,
|
|
15
|
-
} from "./update-audit-render";
|
|
16
|
-
|
|
17
|
-
function row(overrides: Partial<UpdateAuditApiRow> = {}): UpdateAuditApiRow {
|
|
18
|
-
return {
|
|
19
|
-
id: 1,
|
|
20
|
-
state: "success",
|
|
21
|
-
started_at: "2026-05-25 00:00:00",
|
|
22
|
-
duration_ms: 50_000,
|
|
23
|
-
from_digest: `sha256:${"a".repeat(64)}`,
|
|
24
|
-
to_digest: `sha256:${"b".repeat(64)}`,
|
|
25
|
-
prev_image_id: `sha256:${"c".repeat(64)}`,
|
|
26
|
-
backup_path: "/app/data/moor.db.backup-1",
|
|
27
|
-
error_log: null,
|
|
28
|
-
rollback_error: null,
|
|
29
|
-
...overrides,
|
|
30
|
-
};
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
describe("shortDigest", () => {
|
|
34
|
-
test("formats sha256:<64 hex> as sha256:<7>…<7>", () => {
|
|
35
|
-
expect(shortDigest(`sha256:${"a".repeat(64)}`)).toBe(
|
|
36
|
-
`sha256:${"a".repeat(7)}…${"a".repeat(7)}`,
|
|
37
|
-
);
|
|
38
|
-
});
|
|
39
|
-
test("strips the repo prefix from repo@sha256:... form", () => {
|
|
40
|
-
expect(shortDigest(`ghcr.io/caiopizzol/moor@sha256:${"b".repeat(64)}`)).toBe(
|
|
41
|
-
`sha256:${"b".repeat(7)}…${"b".repeat(7)}`,
|
|
42
|
-
);
|
|
43
|
-
});
|
|
44
|
-
test("non-digest input passes through unchanged", () => {
|
|
45
|
-
expect(shortDigest("sha256:short")).toBe("sha256:short");
|
|
46
|
-
expect(shortDigest("not-a-digest")).toBe("not-a-digest");
|
|
47
|
-
});
|
|
48
|
-
test("null/undefined → '(none)'", () => {
|
|
49
|
-
expect(shortDigest(null)).toBe("(none)");
|
|
50
|
-
expect(shortDigest(undefined)).toBe("(none)");
|
|
51
|
-
});
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
describe("fmtDuration", () => {
|
|
55
|
-
test("null → em dash", () => expect(fmtDuration(null)).toBe("—"));
|
|
56
|
-
test("sub-second values in ms", () => expect(fmtDuration(420)).toBe("420ms"));
|
|
57
|
-
test("seconds for [1s, 60s)", () => expect(fmtDuration(50_000)).toBe("50s"));
|
|
58
|
-
test("minutes+seconds at and above 60s", () => {
|
|
59
|
-
expect(fmtDuration(60_000)).toBe("1m0s");
|
|
60
|
-
expect(fmtDuration(125_000)).toBe("2m5s");
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
describe("tailLog", () => {
|
|
65
|
-
test("null/undefined → '' (no field rendered by caller)", () => {
|
|
66
|
-
expect(tailLog(null, 100)).toBe("");
|
|
67
|
-
expect(tailLog(undefined, 100)).toBe("");
|
|
68
|
-
});
|
|
69
|
-
test("short string passes through unchanged", () => {
|
|
70
|
-
expect(tailLog("short", 100)).toBe("short");
|
|
71
|
-
});
|
|
72
|
-
test("long string is tail-truncated with a visible marker", () => {
|
|
73
|
-
const big = "x".repeat(10_000);
|
|
74
|
-
const out = tailLog(big, 1000);
|
|
75
|
-
expect(out).toContain("[...9000 earlier bytes truncated]");
|
|
76
|
-
expect(out.endsWith("x".repeat(1000))).toBe(true);
|
|
77
|
-
});
|
|
78
|
-
test("tail_bytes=0 elides the field body entirely with a sized marker", () => {
|
|
79
|
-
const out = tailLog("hello world", 0);
|
|
80
|
-
expect(out).toBe("[...11 bytes elided (tail_bytes=0)]");
|
|
81
|
-
});
|
|
82
|
-
test("tail_bytes=0 on null/undefined → ''", () => {
|
|
83
|
-
expect(tailLog(null, 0)).toBe("");
|
|
84
|
-
expect(tailLog(undefined, 0)).toBe("");
|
|
85
|
-
});
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
describe("renderAuditRow", () => {
|
|
89
|
-
test("success: includes id, state, duration, from/to, backup; no error_log line", () => {
|
|
90
|
-
const out = renderAuditRow(row());
|
|
91
|
-
expect(out).toContain("audit_id=1");
|
|
92
|
-
expect(out).toContain("state=success");
|
|
93
|
-
expect(out).toContain("duration=50s");
|
|
94
|
-
expect(out).toContain("from: sha256:aaaaaaa");
|
|
95
|
-
expect(out).toContain("to: sha256:bbbbbbb");
|
|
96
|
-
expect(out).toContain("prev_image_id: sha256:ccccccc");
|
|
97
|
-
expect(out).toContain("backup: /app/data/moor.db.backup-1");
|
|
98
|
-
expect(out).not.toContain("error_log");
|
|
99
|
-
expect(out).not.toContain("rollback_error");
|
|
100
|
-
});
|
|
101
|
-
|
|
102
|
-
test("rolled_back: error_log shown, rollback_error omitted", () => {
|
|
103
|
-
const out = renderAuditRow(row({ state: "rolled_back", error_log: "health check failed" }));
|
|
104
|
-
expect(out).toContain("state=rolled_back");
|
|
105
|
-
expect(out).toContain("error_log: health check failed");
|
|
106
|
-
expect(out).not.toContain("rollback_error");
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test("rollback_failed: BOTH error_log and rollback_error rendered", () => {
|
|
110
|
-
const out = renderAuditRow(
|
|
111
|
-
row({
|
|
112
|
-
state: "rollback_failed",
|
|
113
|
-
error_log: "apply up failed",
|
|
114
|
-
rollback_error: "rollback tag failed",
|
|
115
|
-
}),
|
|
116
|
-
);
|
|
117
|
-
expect(out).toContain("state=rollback_failed");
|
|
118
|
-
expect(out).toContain("error_log: apply up failed");
|
|
119
|
-
expect(out).toContain("rollback_error: rollback tag failed");
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test("tail_bytes truncates long error_log per-field with marker", () => {
|
|
123
|
-
const long = "X".repeat(5000);
|
|
124
|
-
const out = renderAuditRow(row({ error_log: long }), { tail_bytes: 100 });
|
|
125
|
-
expect(out).toContain("[...4900 earlier bytes truncated]");
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("tail_bytes=0 elides log body but keeps the field line", () => {
|
|
129
|
-
const out = renderAuditRow(row({ error_log: "anything" }), { tail_bytes: 0 });
|
|
130
|
-
expect(out).toContain("error_log: [...");
|
|
131
|
-
expect(out).toContain("bytes elided (tail_bytes=0)");
|
|
132
|
-
expect(out).not.toContain("anything");
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
test("tail_bytes is clamped to MAX_LOG_TAIL_BYTES", () => {
|
|
136
|
-
// No assertion against the actual byte count; just verify the
|
|
137
|
-
// constant is honored and the function tolerates an over-cap input.
|
|
138
|
-
expect(MAX_LOG_TAIL_BYTES).toBeGreaterThan(DEFAULT_LOG_TAIL_BYTES);
|
|
139
|
-
const long = "Y".repeat(MAX_LOG_TAIL_BYTES + 1000);
|
|
140
|
-
const out = renderAuditRow(row({ error_log: long }), { tail_bytes: 999_999 });
|
|
141
|
-
expect(out).toContain("earlier bytes truncated");
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
test("prev_image_id null → no prev line", () => {
|
|
145
|
-
const out = renderAuditRow(row({ prev_image_id: null }));
|
|
146
|
-
expect(out).not.toContain("prev_image_id");
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
test("backup_path null → no backup line", () => {
|
|
150
|
-
const out = renderAuditRow(row({ backup_path: null }));
|
|
151
|
-
expect(out).not.toContain("backup:");
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
test("non-digest from/to (rare edge) passes through via shortDigest fallback", () => {
|
|
155
|
-
const out = renderAuditRow(row({ from_digest: "weird", to_digest: null }));
|
|
156
|
-
expect(out).toContain("from: weird");
|
|
157
|
-
expect(out).toContain("to: (none)");
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe("renderAuditList", () => {
|
|
162
|
-
test("empty → friendly hint", () => {
|
|
163
|
-
expect(renderAuditList([])).toContain("no update attempts recorded yet");
|
|
164
|
-
});
|
|
165
|
-
test("multi-row separated by blank line", () => {
|
|
166
|
-
const out = renderAuditList([row({ id: 2 }), row({ id: 1 })]);
|
|
167
|
-
expect(out.split("\n\n").length).toBe(2);
|
|
168
|
-
expect(out).toContain("audit_id=2");
|
|
169
|
-
expect(out).toContain("audit_id=1");
|
|
170
|
-
});
|
|
171
|
-
});
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
// #80 PR #6: pure renderers for moor_update_audit. Lives next to its
|
|
2
|
-
// consumer (the MCP tool) so the formatting that operators actually
|
|
3
|
-
// see has direct test coverage.
|
|
4
|
-
//
|
|
5
|
-
// Each row renders as a multi-line block with id, state, duration,
|
|
6
|
-
// digest deltas, prev_image_id, backup path, and per-field tailed
|
|
7
|
-
// error_log / rollback_error.
|
|
8
|
-
|
|
9
|
-
/** API row shape returned by GET /api/server/update/audit. Subset of
|
|
10
|
-
* the full DB row — only what the renderer needs. */
|
|
11
|
-
export type UpdateAuditApiRow = {
|
|
12
|
-
id: number;
|
|
13
|
-
state: string;
|
|
14
|
-
started_at: string;
|
|
15
|
-
duration_ms: number | null;
|
|
16
|
-
from_digest: string | null;
|
|
17
|
-
to_digest: string | null;
|
|
18
|
-
prev_image_id: string | null;
|
|
19
|
-
backup_path: string | null;
|
|
20
|
-
error_log: string | null;
|
|
21
|
-
rollback_error: string | null;
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
export const DEFAULT_LOG_TAIL_BYTES = 4096;
|
|
25
|
-
export const MAX_LOG_TAIL_BYTES = 16384;
|
|
26
|
-
|
|
27
|
-
/** Pure: short-form a sha256 digest for compact rendering (first 7 +
|
|
28
|
-
* last 7). Accepts both raw `sha256:<hex>` and `repo@sha256:<hex>`
|
|
29
|
-
* forms; returns the input unchanged on anything else. */
|
|
30
|
-
export function shortDigest(s: string | null | undefined): string {
|
|
31
|
-
if (!s) return "(none)";
|
|
32
|
-
const m = s.match(/^(?:[^@]+@)?sha256:([0-9a-f]{64})$/);
|
|
33
|
-
if (!m) return s;
|
|
34
|
-
const hex = m[1];
|
|
35
|
-
return `sha256:${hex.slice(0, 7)}…${hex.slice(-7)}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Pure: human-readable duration. Mirrors the format moor_runs uses. */
|
|
39
|
-
export function fmtDuration(ms: number | null): string {
|
|
40
|
-
if (ms == null) return "—";
|
|
41
|
-
if (ms < 1000) return `${ms}ms`;
|
|
42
|
-
const s = Math.round(ms / 1000);
|
|
43
|
-
if (s < 60) return `${s}s`;
|
|
44
|
-
const m = Math.floor(s / 60);
|
|
45
|
-
const rem = s % 60;
|
|
46
|
-
return `${m}m${rem}s`;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Pure: tail-truncate a string to maxBytes with a visible marker.
|
|
50
|
-
* maxBytes=0 means omit the field body entirely (returns the marker
|
|
51
|
-
* alone). Used per-field for error_log + rollback_error so a
|
|
52
|
-
* crashed update with a long log doesn't blow up the token budget. */
|
|
53
|
-
export function tailLog(s: string | null | undefined, maxBytes: number): string {
|
|
54
|
-
if (s == null) return "";
|
|
55
|
-
if (maxBytes <= 0) {
|
|
56
|
-
const enc = new TextEncoder();
|
|
57
|
-
return `[...${enc.encode(s).length} bytes elided (tail_bytes=0)]`;
|
|
58
|
-
}
|
|
59
|
-
const enc = new TextEncoder();
|
|
60
|
-
const bytes = enc.encode(s);
|
|
61
|
-
if (bytes.length <= maxBytes) return s;
|
|
62
|
-
const dropped = bytes.length - maxBytes;
|
|
63
|
-
const tail = new TextDecoder("utf-8", { fatal: false }).decode(bytes.subarray(dropped));
|
|
64
|
-
return `[...${dropped} earlier bytes truncated]\n${tail}`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Pure: render one audit row. tail_bytes applies separately to
|
|
68
|
-
* error_log and rollback_error; absent fields are skipped. */
|
|
69
|
-
export function renderAuditRow(row: UpdateAuditApiRow, opts: { tail_bytes?: number } = {}): string {
|
|
70
|
-
const tail = Math.min(opts.tail_bytes ?? DEFAULT_LOG_TAIL_BYTES, MAX_LOG_TAIL_BYTES);
|
|
71
|
-
const lines: string[] = [
|
|
72
|
-
`audit_id=${row.id} state=${row.state} duration=${fmtDuration(row.duration_ms)} started=${row.started_at}`,
|
|
73
|
-
` from: ${shortDigest(row.from_digest)} → to: ${shortDigest(row.to_digest)}`,
|
|
74
|
-
];
|
|
75
|
-
if (row.prev_image_id) lines.push(` prev_image_id: ${shortDigest(row.prev_image_id)}`);
|
|
76
|
-
if (row.backup_path) lines.push(` backup: ${row.backup_path}`);
|
|
77
|
-
if (row.error_log) lines.push(` error_log: ${tailLog(row.error_log, tail)}`);
|
|
78
|
-
if (row.rollback_error) {
|
|
79
|
-
lines.push(` rollback_error: ${tailLog(row.rollback_error, tail)}`);
|
|
80
|
-
}
|
|
81
|
-
return lines.join("\n");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
/** Pure: render a list of rows separated by a blank line. Empty list
|
|
85
|
-
* returns the documented "no updates yet" string. */
|
|
86
|
-
export function renderAuditList(
|
|
87
|
-
rows: UpdateAuditApiRow[],
|
|
88
|
-
opts: { tail_bytes?: number } = {},
|
|
89
|
-
): string {
|
|
90
|
-
if (rows.length === 0) {
|
|
91
|
-
return "no update attempts recorded yet. Run moor_update_apply to start one.";
|
|
92
|
-
}
|
|
93
|
-
return rows.map((r) => renderAuditRow(r, opts)).join("\n\n");
|
|
94
|
-
}
|