@mux-magic/tools 1.0.0 → 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.
- package/package.json +1 -1
- package/src/applyRenameRegex.test.ts +49 -0
- package/src/applyRenameRegex.ts +22 -0
- package/src/index.ts +42 -0
- package/src/logMessage.test.ts +112 -9
- package/src/logMessage.ts +29 -0
- package/src/logging/context.test.ts +57 -0
- package/src/logging/context.ts +23 -0
- package/src/logging/lineSink.test.ts +135 -0
- package/src/logging/lineSink.ts +74 -0
- package/src/logging/logger.test.ts +154 -0
- package/src/logging/logger.ts +96 -0
- package/src/logging/mode.test.ts +36 -0
- package/src/logging/mode.ts +29 -0
- package/src/logging/startSpan.test.ts +150 -0
- package/src/logging/startSpan.ts +51 -0
- package/src/sourcePath.test.ts +16 -0
- package/src/sourcePath.ts +17 -0
- package/src/taskScheduler.injection.test.ts +72 -0
- package/src/taskScheduler.test.ts +673 -0
- package/src/taskScheduler.ts +414 -0
|
@@ -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
|
+
})
|