@mux-magic/tools 0.1.2 → 1.1.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.
@@ -0,0 +1,154 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ test,
7
+ vi,
8
+ } from "vitest"
9
+
10
+ import { withLoggingContext } from "./context.js"
11
+ import {
12
+ __resetLogSinksForTests,
13
+ getLogger,
14
+ type LogRecord,
15
+ registerLogSink,
16
+ } from "./logger.js"
17
+
18
+ describe(getLogger.name, () => {
19
+ let records: readonly LogRecord[]
20
+
21
+ beforeEach(() => {
22
+ __resetLogSinksForTests()
23
+ records = []
24
+ registerLogSink((record) => {
25
+ records = records.concat(record)
26
+ })
27
+ })
28
+
29
+ afterEach(() => {
30
+ __resetLogSinksForTests()
31
+ })
32
+
33
+ test("emits one record per .info call", () => {
34
+ getLogger().info("hello")
35
+
36
+ expect(records).toHaveLength(1)
37
+ expect(records[0]).toMatchObject({
38
+ level: "info",
39
+ msg: "hello",
40
+ })
41
+ })
42
+
43
+ test("emits one record per .debug / .warn / .error call", () => {
44
+ const logger = getLogger()
45
+
46
+ logger.debug("d")
47
+ logger.warn("w")
48
+ logger.error("e")
49
+
50
+ expect(records.map((record) => record.level)).toEqual([
51
+ "debug",
52
+ "warn",
53
+ "error",
54
+ ])
55
+ })
56
+
57
+ test("attaches caller-provided extra fields to the record", () => {
58
+ getLogger().info("step done", {
59
+ stepIndex: 2,
60
+ detail: "copy",
61
+ })
62
+
63
+ expect(records[0]).toMatchObject({
64
+ level: "info",
65
+ msg: "step done",
66
+ stepIndex: 2,
67
+ detail: "copy",
68
+ })
69
+ })
70
+
71
+ test("reads jobId / stepIndex / fileId from AsyncLocalStorage context", () => {
72
+ withLoggingContext(
73
+ { jobId: "j1", stepIndex: 4, fileId: "f.mkv" },
74
+ () => {
75
+ getLogger().info("hi")
76
+ },
77
+ )
78
+
79
+ expect(records[0]).toMatchObject({
80
+ jobId: "j1",
81
+ stepIndex: 4,
82
+ fileId: "f.mkv",
83
+ level: "info",
84
+ msg: "hi",
85
+ })
86
+ })
87
+
88
+ test("explicit fields override context-derived fields", () => {
89
+ withLoggingContext({ jobId: "from-ctx" }, () => {
90
+ getLogger().info("override", { jobId: "explicit" })
91
+ })
92
+
93
+ expect(records[0]).toMatchObject({
94
+ jobId: "explicit",
95
+ msg: "override",
96
+ })
97
+ })
98
+
99
+ test("child(bindings) merges bindings into every emitted record", () => {
100
+ const childLogger = getLogger().child({
101
+ component: "renamer",
102
+ })
103
+
104
+ childLogger.info("named")
105
+ childLogger.warn("careful")
106
+
107
+ expect(records[0]).toMatchObject({
108
+ component: "renamer",
109
+ msg: "named",
110
+ })
111
+ expect(records[1]).toMatchObject({
112
+ component: "renamer",
113
+ msg: "careful",
114
+ })
115
+ })
116
+
117
+ test("child bindings override context, explicit extras override child", () => {
118
+ withLoggingContext({ stepIndex: 1 }, () => {
119
+ const child = getLogger().child({ stepIndex: 2 })
120
+
121
+ child.info("first")
122
+ child.info("second", { stepIndex: 3 })
123
+ })
124
+
125
+ expect(records[0]?.stepIndex).toBe(2)
126
+ expect(records[1]?.stepIndex).toBe(3)
127
+ })
128
+
129
+ test("registerLogSink returns an unsubscribe handle", () => {
130
+ const sink = vi.fn()
131
+ const unsubscribe = registerLogSink(sink)
132
+
133
+ getLogger().info("first")
134
+ unsubscribe()
135
+ getLogger().info("second")
136
+
137
+ expect(sink).toHaveBeenCalledTimes(1)
138
+ expect(sink).toHaveBeenCalledWith(
139
+ expect.objectContaining({ msg: "first" }),
140
+ )
141
+ })
142
+
143
+ test("multiple sinks each receive every record", () => {
144
+ const sinkA = vi.fn()
145
+ const sinkB = vi.fn()
146
+ registerLogSink(sinkA)
147
+ registerLogSink(sinkB)
148
+
149
+ getLogger().info("broadcast")
150
+
151
+ expect(sinkA).toHaveBeenCalledTimes(1)
152
+ expect(sinkB).toHaveBeenCalledTimes(1)
153
+ })
154
+ })
@@ -0,0 +1,96 @@
1
+ import { getLoggingContext } from "./context.js"
2
+ import { startSpan } from "./startSpan.js"
3
+
4
+ export type LogLevel = "debug" | "info" | "warn" | "error"
5
+
6
+ export type LogRecord = {
7
+ level: LogLevel
8
+ msg: string
9
+ jobId?: string
10
+ stepIndex?: number
11
+ fileId?: string
12
+ traceId?: string
13
+ spanId?: string
14
+ [extraKey: string]: unknown
15
+ }
16
+
17
+ export type LogSink = (record: LogRecord) => void
18
+
19
+ export type Logger = {
20
+ debug: (
21
+ msg: string,
22
+ extra?: Record<string, unknown>,
23
+ ) => void
24
+ info: (
25
+ msg: string,
26
+ extra?: Record<string, unknown>,
27
+ ) => void
28
+ warn: (
29
+ msg: string,
30
+ extra?: Record<string, unknown>,
31
+ ) => void
32
+ error: (
33
+ msg: string,
34
+ extra?: Record<string, unknown>,
35
+ ) => void
36
+ child: (bindings: Record<string, unknown>) => Logger
37
+ startSpan: <T>(
38
+ name: string,
39
+ fn: () => Promise<T> | T,
40
+ ) => Promise<T>
41
+ }
42
+
43
+ const sinks: Set<LogSink> = new Set()
44
+
45
+ export const registerLogSink = (
46
+ sink: LogSink,
47
+ ): (() => void) => {
48
+ sinks.add(sink)
49
+ return () => {
50
+ sinks.delete(sink)
51
+ }
52
+ }
53
+
54
+ export const __resetLogSinksForTests = (): void => {
55
+ sinks.clear()
56
+ }
57
+
58
+ const emit = (record: LogRecord): void => {
59
+ for (const sink of sinks) {
60
+ sink(record)
61
+ }
62
+ }
63
+
64
+ const buildRecord = (
65
+ bindings: Record<string, unknown>,
66
+ level: LogLevel,
67
+ msg: string,
68
+ extra: Record<string, unknown> | undefined,
69
+ ): LogRecord => ({
70
+ ...getLoggingContext(),
71
+ ...bindings,
72
+ ...extra,
73
+ level,
74
+ msg,
75
+ })
76
+
77
+ const createLogger = (
78
+ bindings: Record<string, unknown>,
79
+ ): Logger => {
80
+ const logger: Logger = {
81
+ debug: (msg, extra) =>
82
+ emit(buildRecord(bindings, "debug", msg, extra)),
83
+ info: (msg, extra) =>
84
+ emit(buildRecord(bindings, "info", msg, extra)),
85
+ warn: (msg, extra) =>
86
+ emit(buildRecord(bindings, "warn", msg, extra)),
87
+ error: (msg, extra) =>
88
+ emit(buildRecord(bindings, "error", msg, extra)),
89
+ child: (childBindings) =>
90
+ createLogger({ ...bindings, ...childBindings }),
91
+ startSpan: (name, fn) => startSpan(logger, name, fn),
92
+ }
93
+ return logger
94
+ }
95
+
96
+ export const getLogger = (): Logger => createLogger({})
@@ -0,0 +1,36 @@
1
+ import { afterEach, describe, expect, test } from "vitest"
2
+
3
+ import {
4
+ __resetLoggingModeForTests,
5
+ getLoggingMode,
6
+ setLoggingMode,
7
+ } from "./mode.js"
8
+
9
+ afterEach(() => {
10
+ __resetLoggingModeForTests()
11
+ })
12
+
13
+ describe(getLoggingMode.name, () => {
14
+ test('defaults to "cli"', () => {
15
+ expect(getLoggingMode()).toBe("cli")
16
+ })
17
+
18
+ test('returns "api" after setLoggingMode("api")', () => {
19
+ setLoggingMode("api")
20
+
21
+ expect(getLoggingMode()).toBe("api")
22
+ })
23
+
24
+ test('returns "cli-debug" after setLoggingMode("cli-debug")', () => {
25
+ setLoggingMode("cli-debug")
26
+
27
+ expect(getLoggingMode()).toBe("cli-debug")
28
+ })
29
+
30
+ test("reset helper returns to the default", () => {
31
+ setLoggingMode("api")
32
+ __resetLoggingModeForTests()
33
+
34
+ expect(getLoggingMode()).toBe("cli")
35
+ })
36
+ })
@@ -0,0 +1,29 @@
1
+ // Logging mode controls how the user-friendly helpers
2
+ // (`logInfo` / `logError` / `logWarning` in ./logMessage.ts) deliver their
3
+ // output. The structured logger (`getLogger`) is always available regardless
4
+ // of mode — the mode only governs the legacy helpers' delivery path.
5
+ //
6
+ // "cli" — chalk-coloured `[TAG] message` console output. No
7
+ // structured emission. Default; preserves historical
8
+ // behaviour for any consumer that does not opt in.
9
+ // "api" — emit a structured `LogRecord` (with a `tag` field) to
10
+ // the registered sinks. No chalk console output. Used by
11
+ // the API server, where the log surface is the web UI's
12
+ // job-log SSE feed, not a human terminal.
13
+ // "cli-debug" — both. Lets a CLI run capture structured records (for
14
+ // debug logging to a file, for example) without losing
15
+ // the user-facing console output.
16
+
17
+ export type LoggingMode = "cli" | "api" | "cli-debug"
18
+
19
+ let currentMode: LoggingMode = "cli"
20
+
21
+ export const setLoggingMode = (mode: LoggingMode): void => {
22
+ currentMode = mode
23
+ }
24
+
25
+ export const getLoggingMode = (): LoggingMode => currentMode
26
+
27
+ export const __resetLoggingModeForTests = (): void => {
28
+ currentMode = "cli"
29
+ }
@@ -0,0 +1,150 @@
1
+ import {
2
+ afterEach,
3
+ beforeEach,
4
+ describe,
5
+ expect,
6
+ test,
7
+ } from "vitest"
8
+
9
+ import { getLoggingContext } from "./context.js"
10
+ import {
11
+ __resetLogSinksForTests,
12
+ getLogger,
13
+ type LogRecord,
14
+ registerLogSink,
15
+ } from "./logger.js"
16
+
17
+ describe("startSpan", () => {
18
+ let records: readonly LogRecord[]
19
+
20
+ beforeEach(() => {
21
+ __resetLogSinksForTests()
22
+ records = []
23
+ registerLogSink((record) => {
24
+ records = records.concat(record)
25
+ })
26
+ })
27
+
28
+ afterEach(() => {
29
+ __resetLogSinksForTests()
30
+ })
31
+
32
+ test("returns the value the wrapped fn returned", async () => {
33
+ const result = await getLogger().startSpan(
34
+ "work",
35
+ () => 7,
36
+ )
37
+
38
+ expect(result).toBe(7)
39
+ })
40
+
41
+ test("awaits async fn results", async () => {
42
+ const result = await getLogger().startSpan(
43
+ "work",
44
+ async () => {
45
+ await Promise.resolve()
46
+ return "done"
47
+ },
48
+ )
49
+
50
+ expect(result).toBe("done")
51
+ })
52
+
53
+ test("emits an enter and an exit debug record sharing traceId/spanId", async () => {
54
+ await getLogger().startSpan("work", () => undefined)
55
+
56
+ const enter = records.find((record) =>
57
+ record.msg.startsWith("span enter:"),
58
+ )
59
+ const exit = records.find((record) =>
60
+ record.msg.startsWith("span exit:"),
61
+ )
62
+
63
+ expect(enter).toBeDefined()
64
+ expect(exit).toBeDefined()
65
+ expect(enter?.traceId).toBeTypeOf("string")
66
+ expect(enter?.spanId).toBeTypeOf("string")
67
+ expect(exit?.traceId).toBe(enter?.traceId)
68
+ expect(exit?.spanId).toBe(enter?.spanId)
69
+ expect(exit?.spanName).toBe("work")
70
+ })
71
+
72
+ test("exit record carries elapsedMs >= 0", async () => {
73
+ await getLogger().startSpan("work", () => undefined)
74
+
75
+ const exit = records.find((record) =>
76
+ record.msg.startsWith("span exit:"),
77
+ )
78
+
79
+ expect(typeof exit?.elapsedMs).toBe("number")
80
+ expect(
81
+ exit?.elapsedMs as number,
82
+ ).toBeGreaterThanOrEqual(0)
83
+ })
84
+
85
+ test("propagates traceId/spanId into the AsyncLocalStorage context", async () => {
86
+ let innerContext = {} as ReturnType<
87
+ typeof getLoggingContext
88
+ >
89
+
90
+ await getLogger().startSpan("work", () => {
91
+ innerContext = getLoggingContext()
92
+ })
93
+
94
+ expect(innerContext.traceId).toBeTypeOf("string")
95
+ expect(innerContext.spanId).toBeTypeOf("string")
96
+ })
97
+
98
+ test("logger.info inside fn carries the span's traceId/spanId", async () => {
99
+ await getLogger().startSpan("work", () => {
100
+ getLogger().info("inner")
101
+ })
102
+
103
+ const inner = records.find(
104
+ (record) => record.msg === "inner",
105
+ )
106
+
107
+ expect(inner?.traceId).toBeTypeOf("string")
108
+ expect(inner?.spanId).toBeTypeOf("string")
109
+ })
110
+
111
+ test("nested span inherits the outer traceId but gets a fresh spanId", async () => {
112
+ let outerTraceId = ""
113
+ let outerSpanId = ""
114
+ let innerTraceId = ""
115
+ let innerSpanId = ""
116
+
117
+ await getLogger().startSpan("outer", async () => {
118
+ const outerCtx = getLoggingContext()
119
+ outerTraceId = outerCtx.traceId ?? ""
120
+ outerSpanId = outerCtx.spanId ?? ""
121
+
122
+ await getLogger().startSpan("inner", () => {
123
+ const innerCtx = getLoggingContext()
124
+ innerTraceId = innerCtx.traceId ?? ""
125
+ innerSpanId = innerCtx.spanId ?? ""
126
+ })
127
+ })
128
+
129
+ expect(outerTraceId).not.toBe("")
130
+ expect(innerTraceId).toBe(outerTraceId)
131
+ expect(innerSpanId).not.toBe("")
132
+ expect(innerSpanId).not.toBe(outerSpanId)
133
+ })
134
+
135
+ test("emits an error record with elapsedMs/errorName and re-throws on throw", async () => {
136
+ await expect(
137
+ getLogger().startSpan("work", () => {
138
+ throw new TypeError("nope")
139
+ }),
140
+ ).rejects.toBeInstanceOf(TypeError)
141
+
142
+ const errRecord = records.find((record) =>
143
+ record.msg.startsWith("span error:"),
144
+ )
145
+
146
+ expect(errRecord?.level).toBe("error")
147
+ expect(errRecord?.errorName).toBe("TypeError")
148
+ expect(typeof errRecord?.elapsedMs).toBe("number")
149
+ })
150
+ })
@@ -0,0 +1,51 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { performance } from "node:perf_hooks"
3
+
4
+ import {
5
+ getLoggingContext,
6
+ loggingContext,
7
+ } from "./context.js"
8
+ import type { Logger } from "./logger.js"
9
+
10
+ export const startSpan = async <T>(
11
+ logger: Logger,
12
+ name: string,
13
+ fn: () => Promise<T> | T,
14
+ ): Promise<T> => {
15
+ const parentContext = getLoggingContext()
16
+ const traceId = parentContext.traceId ?? randomUUID()
17
+ const spanId = randomUUID()
18
+ const startedAt = performance.now()
19
+
20
+ logger.debug(`span enter: ${name}`, {
21
+ traceId,
22
+ spanId,
23
+ spanName: name,
24
+ })
25
+
26
+ return loggingContext.run(
27
+ { ...parentContext, traceId, spanId },
28
+ async () => {
29
+ try {
30
+ const result = await fn()
31
+ logger.debug(`span exit: ${name}`, {
32
+ traceId,
33
+ spanId,
34
+ spanName: name,
35
+ elapsedMs: performance.now() - startedAt,
36
+ })
37
+ return result
38
+ } catch (error) {
39
+ logger.error(`span error: ${name}`, {
40
+ traceId,
41
+ spanId,
42
+ spanName: name,
43
+ elapsedMs: performance.now() - startedAt,
44
+ errorName:
45
+ error instanceof Error ? error.name : "unknown",
46
+ })
47
+ throw error
48
+ }
49
+ },
50
+ )
51
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "vitest"
2
+
3
+ import {
4
+ SOURCE_PATH_FIELD_NAME,
5
+ SOURCE_PATH_LABEL,
6
+ } from "./sourcePath.js"
7
+
8
+ describe("sourcePath constants", () => {
9
+ test("SOURCE_PATH_FIELD_NAME is the canonical internal field name", () => {
10
+ expect(SOURCE_PATH_FIELD_NAME).toBe("sourcePath")
11
+ })
12
+
13
+ test("SOURCE_PATH_LABEL is the canonical user-facing label", () => {
14
+ expect(SOURCE_PATH_LABEL).toBe("Source Path")
15
+ })
16
+ })
@@ -0,0 +1,17 @@
1
+ // Canonical "Source Path" concept.
2
+ //
3
+ // Every command that takes a primary input directory exposes its value as a
4
+ // field named `sourcePath` (via this constant) and renders it under the label
5
+ // "Source Path". Centralising the spelling here keeps server schemas, the
6
+ // CLI's option name, the web field-builder, and the YAML codec's legacy-rename
7
+ // map all in agreement — a single rename here would still ripple if any
8
+ // caller hardcoded the string.
9
+
10
+ export const SOURCE_PATH_FIELD_NAME = "sourcePath" as const
11
+ export const SOURCE_PATH_LABEL = "Source Path" as const
12
+
13
+ // Canonical: an absolute filesystem path. Today the validation layer is
14
+ // nominal — every string is a SourcePath. The alias documents intent at
15
+ // callsites and gives a single place to tighten later (e.g. brand the type)
16
+ // without a codebase-wide replace.
17
+ export type SourcePath = string
@@ -0,0 +1,72 @@
1
+ import { defer, of } from "rxjs"
2
+ import {
3
+ afterEach,
4
+ beforeEach,
5
+ describe,
6
+ expect,
7
+ test,
8
+ } from "vitest"
9
+
10
+ import {
11
+ __resetTaskSchedulerForTests,
12
+ initTaskScheduler,
13
+ runTask,
14
+ } from "./taskScheduler.js"
15
+
16
+ // These tests pin worker 21's design contract:
17
+ // 1. The scheduler now lives in @mux-magic/tools (this file's location
18
+ // proves the file moved).
19
+ // 2. The scheduler no longer hard-imports server-only modules; the
20
+ // server-specific job-id provider is injected at init.
21
+
22
+ beforeEach(() => {
23
+ __resetTaskSchedulerForTests()
24
+ })
25
+
26
+ afterEach(() => {
27
+ __resetTaskSchedulerForTests()
28
+ })
29
+
30
+ describe("initTaskScheduler — injected getActiveJobId", () => {
31
+ test("uses the injected provider when explicitJobId is omitted", () => {
32
+ let observedJobId: string | null = "unset"
33
+ let injectedReadCount = 0
34
+
35
+ initTaskScheduler(1, {
36
+ getActiveJobId: () => {
37
+ injectedReadCount += 1
38
+ return "injected-job"
39
+ },
40
+ })
41
+
42
+ runTask(
43
+ defer(() => {
44
+ observedJobId = "did-run"
45
+ return of(undefined)
46
+ }),
47
+ ).subscribe()
48
+
49
+ // The injected provider must have been consulted at subscribe time
50
+ // (the scheduler reads jobId via the injected fn, not via a
51
+ // server-only import).
52
+ expect(injectedReadCount).toBeGreaterThan(0)
53
+ expect(observedJobId).toBe("did-run")
54
+ })
55
+
56
+ test("defaults to a null provider when no injection is supplied", () => {
57
+ initTaskScheduler(1)
58
+
59
+ let hasRun = false
60
+
61
+ runTask(
62
+ defer(() => {
63
+ hasRun = true
64
+ return of(undefined)
65
+ }),
66
+ ).subscribe()
67
+
68
+ // CLI passes no provider; scheduler must still function and treat
69
+ // the active job id as null (no per-job claim consulted).
70
+ expect(hasRun).toBe(true)
71
+ })
72
+ })