Merge branch 'puter/issue-9-gitea-smoke' into puter/issue-14-integration
# Conflicts: # packages/primitives/package.json
This commit is contained in:
4
packages/env/package.json
vendored
4
packages/env/package.json
vendored
@@ -8,8 +8,12 @@
|
||||
"./convex": "./src/convex.ts",
|
||||
"./native": "./src/native.ts",
|
||||
"./server": "./src/server.ts",
|
||||
"./smoke": "./src/smoke.ts",
|
||||
"./web": "./src/web.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"zod": "catalog:"
|
||||
|
||||
46
packages/env/src/smoke.ts
vendored
Normal file
46
packages/env/src/smoke.ts
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const smokeEnvSchema = z.object({
|
||||
accessToken: z.string().min(1),
|
||||
agentModelBaseUrl: z.url().optional(),
|
||||
agentModelName: z.string().min(1).optional(),
|
||||
agentModelProvider: z.string().min(1).optional(),
|
||||
convexUrl: z.url(),
|
||||
cpaApiKey: z.string().min(1),
|
||||
cpaBaseUrl: z.url(),
|
||||
daemonId: z.string().min(1),
|
||||
featureRequest: z.string().min(10),
|
||||
flueUrl: z.url(),
|
||||
projectId: z.string().min(1).optional(),
|
||||
webUrl: z.url(),
|
||||
});
|
||||
|
||||
export type SmokeEnv = z.infer<typeof smokeEnvSchema>;
|
||||
|
||||
export const parseSmokeEnv = (
|
||||
runtimeEnv: Record<string, string | undefined>
|
||||
): SmokeEnv =>
|
||||
smokeEnvSchema.parse({
|
||||
accessToken: runtimeEnv.ZOPU_SMOKE_ACCESS_TOKEN,
|
||||
agentModelBaseUrl: runtimeEnv.AGENT_MODEL_BASE_URL,
|
||||
agentModelName: runtimeEnv.AGENT_MODEL_NAME,
|
||||
agentModelProvider: runtimeEnv.AGENT_MODEL_PROVIDER,
|
||||
convexUrl: runtimeEnv.ZOPU_SMOKE_CONVEX_URL ?? runtimeEnv.CONVEX_URL,
|
||||
cpaApiKey:
|
||||
runtimeEnv.ZOPU_SMOKE_CPA_API_KEY ?? runtimeEnv.AGENT_MODEL_API_KEY,
|
||||
cpaBaseUrl:
|
||||
runtimeEnv.ZOPU_SMOKE_CPA_BASE_URL ?? runtimeEnv.AGENT_MODEL_BASE_URL,
|
||||
daemonId: runtimeEnv.ZOPU_SMOKE_DAEMON_ID ?? runtimeEnv.DAEMON_ID,
|
||||
featureRequest:
|
||||
runtimeEnv.ZOPU_SMOKE_FEATURE_REQUEST ??
|
||||
"Add a tiny accessible status control to the existing web project's primary page, keep existing behavior unchanged, and add a focused test for it.",
|
||||
flueUrl:
|
||||
runtimeEnv.ZOPU_SMOKE_FLUE_URL ??
|
||||
runtimeEnv.VITE_FLUE_URL ??
|
||||
"http://localhost:3583",
|
||||
projectId: runtimeEnv.ZOPU_SMOKE_PROJECT_ID,
|
||||
webUrl:
|
||||
runtimeEnv.ZOPU_SMOKE_WEB_URL ??
|
||||
runtimeEnv.SITE_URL ??
|
||||
"http://localhost:5173",
|
||||
});
|
||||
@@ -11,7 +11,8 @@
|
||||
"./project-issue": "./src/project-issue.ts",
|
||||
"./project-workspace": "./src/project-workspace.ts",
|
||||
"./project-work": "./src/project-work.ts",
|
||||
"./signal": "./src/signal.ts"
|
||||
"./signal": "./src/signal.ts",
|
||||
"./smoke": "./src/smoke.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit",
|
||||
|
||||
@@ -6,3 +6,4 @@ export * from "./project-issue";
|
||||
export * from "./project-workspace";
|
||||
export * from "./project-work";
|
||||
export * from "./signal";
|
||||
export * from "./smoke";
|
||||
|
||||
164
packages/primitives/src/smoke.test.ts
Normal file
164
packages/primitives/src/smoke.test.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Effect } from "effect";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
SmokeContractError,
|
||||
advanceSmokeLoop,
|
||||
decodeSmokeChatText,
|
||||
decodeSmokeModelCatalog,
|
||||
decodeSmokePlan,
|
||||
decodeSmokeReview,
|
||||
decodeSmokeWorkerReport,
|
||||
initialSmokeLoopState,
|
||||
redactSmokeText,
|
||||
selectSmokeModelRoles,
|
||||
validateSmokeCapabilities,
|
||||
} from "./smoke";
|
||||
|
||||
const validRoleSelection = () => ({
|
||||
plannerReviewerModel: "glm-5.2",
|
||||
plannerReviewerProvider: "cheaptricks",
|
||||
workerModel: "minimax-m3",
|
||||
workerProvider: "cheaptricks",
|
||||
workerToolCallMode: "serial" as const,
|
||||
});
|
||||
|
||||
const run = <A, E>(effect: Effect.Effect<A, E>): A => Effect.runSync(effect);
|
||||
|
||||
describe("smoke primitives", () => {
|
||||
it("selects the planner/reviewer and serial M3 worker roles", () => {
|
||||
const roles = run(selectSmokeModelRoles(validRoleSelection()));
|
||||
|
||||
expect(roles.worker).toMatchObject({
|
||||
model: "minimax-m3",
|
||||
role: "worker",
|
||||
toolCallMode: "serial",
|
||||
});
|
||||
expect(roles.plannerReviewer).toMatchObject({
|
||||
model: "glm-5.2",
|
||||
role: "planner-reviewer",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a non-M3 worker or non-GLM planner", () => {
|
||||
const worker = validRoleSelection();
|
||||
worker.workerModel = "glm-5.2";
|
||||
const workerError = run(Effect.flip(selectSmokeModelRoles(worker)));
|
||||
expect(workerError).toBeInstanceOf(SmokeContractError);
|
||||
expect(workerError.reason).toBe("InvalidModelRole");
|
||||
|
||||
const planner = validRoleSelection();
|
||||
planner.plannerReviewerModel = "minimax-m3";
|
||||
const plannerError = run(Effect.flip(selectSmokeModelRoles(planner)));
|
||||
expect(plannerError.reason).toBe("InvalidModelRole");
|
||||
});
|
||||
|
||||
it("advances only through the serial smoke loop", () => {
|
||||
let state = initialSmokeLoopState();
|
||||
state = run(advanceSmokeLoop(state, "preflight-passed"));
|
||||
state = run(advanceSmokeLoop(state, "plan-validated"));
|
||||
state = run(advanceSmokeLoop(state, "worker-succeeded"));
|
||||
state = run(advanceSmokeLoop(state, "review-approved"));
|
||||
|
||||
expect(state.phase).toBe("completed");
|
||||
expect(state.history).toEqual([
|
||||
"preflight",
|
||||
"planning",
|
||||
"worker-running",
|
||||
"reviewing",
|
||||
"completed",
|
||||
]);
|
||||
});
|
||||
|
||||
it("turns contract failures into a terminal state", () => {
|
||||
const state = run(
|
||||
advanceSmokeLoop(
|
||||
initialSmokeLoopState(),
|
||||
"contract-failed",
|
||||
"daemon is offline"
|
||||
)
|
||||
);
|
||||
|
||||
expect(state.phase).toBe("failed");
|
||||
expect(state.lastError).toBe("daemon is offline");
|
||||
});
|
||||
|
||||
it("rejects out-of-order loop transitions", () => {
|
||||
const error = run(
|
||||
Effect.flip(advanceSmokeLoop(initialSmokeLoopState(), "review-approved"))
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(SmokeContractError);
|
||||
expect(error.reason).toBe("InvalidTransition");
|
||||
});
|
||||
|
||||
it("validates model reports at the primitive boundary", () => {
|
||||
const plan = run(
|
||||
decodeSmokePlan({
|
||||
acceptanceCriteria: ["The button renders"],
|
||||
nonGoals: [],
|
||||
steps: ["Add the button", "Run the web check"],
|
||||
summary: "Add a small button to the disposable project",
|
||||
})
|
||||
);
|
||||
const worker = run(
|
||||
decodeSmokeWorkerReport({
|
||||
status: "completed",
|
||||
summary: "Changed the project and ran the targeted check.",
|
||||
})
|
||||
);
|
||||
const review = run(
|
||||
decodeSmokeReview({
|
||||
approved: true,
|
||||
findings: [],
|
||||
summary: "The change matches the acceptance criteria.",
|
||||
})
|
||||
);
|
||||
|
||||
expect(plan.steps).toHaveLength(2);
|
||||
expect(worker.status).toBe("completed");
|
||||
expect(review.approved).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects empty or malformed model output", () => {
|
||||
const empty = run(
|
||||
Effect.flip(
|
||||
decodeSmokeChatText({ choices: [{ message: { content: " " } }] })
|
||||
)
|
||||
);
|
||||
expect(empty.reason).toBe("InvalidModelResponse");
|
||||
|
||||
const malformed = run(Effect.flip(decodeSmokeReview({ approved: true })));
|
||||
expect(malformed.reason).toBe("InvalidReport");
|
||||
});
|
||||
|
||||
it("validates the canonical CPA model catalog", () => {
|
||||
const catalog = run(
|
||||
decodeSmokeModelCatalog({
|
||||
data: [{ id: "minimax-m3" }, { id: "glm-5.2" }],
|
||||
})
|
||||
);
|
||||
|
||||
expect(catalog.data.map(({ id }) => id)).toEqual(["minimax-m3", "glm-5.2"]);
|
||||
});
|
||||
|
||||
it("fails a capability report when any required check fails", () => {
|
||||
const error = run(
|
||||
Effect.flip(
|
||||
validateSmokeCapabilities([
|
||||
{ id: "web", message: "ready", passed: true },
|
||||
{ id: "daemon", message: "daemon is offline", passed: false },
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
expect(error.reason).toBe("InvalidReport");
|
||||
expect(error.message).toContain("daemon is offline");
|
||||
});
|
||||
|
||||
it("redacts secrets without changing unrelated text", () => {
|
||||
expect(
|
||||
redactSmokeText("status token=secret-key endpoint=ok", ["secret-key"])
|
||||
).toBe("status token=[REDACTED] endpoint=ok");
|
||||
});
|
||||
});
|
||||
347
packages/primitives/src/smoke.ts
Normal file
347
packages/primitives/src/smoke.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
/* eslint-disable max-classes-per-file -- the smoke contract keeps its schemas, errors, and transitions together. */
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
const MeaningfulString = Schema.String.check(
|
||||
Schema.makeFilter((value) => value.trim().length > 0, {
|
||||
expected: "a non-empty string",
|
||||
})
|
||||
);
|
||||
|
||||
const SmokePhase = Schema.Literals([
|
||||
"preflight",
|
||||
"planning",
|
||||
"worker-running",
|
||||
"reviewing",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
export type SmokePhase = typeof SmokePhase.Type;
|
||||
|
||||
const SmokeTransition = Schema.Literals([
|
||||
"preflight-passed",
|
||||
"plan-validated",
|
||||
"worker-succeeded",
|
||||
"review-approved",
|
||||
"contract-failed",
|
||||
"runtime-failed",
|
||||
]);
|
||||
type SmokeTransition = typeof SmokeTransition.Type;
|
||||
|
||||
export const SmokeRole = Schema.Literals(["planner-reviewer", "worker"]);
|
||||
export type SmokeRole = typeof SmokeRole.Type;
|
||||
|
||||
const SmokeToolCallMode = Schema.Literal("serial");
|
||||
|
||||
export const SmokeRoleSelectionInput = Schema.Struct({
|
||||
plannerReviewerModel: MeaningfulString,
|
||||
plannerReviewerProvider: MeaningfulString,
|
||||
workerModel: MeaningfulString,
|
||||
workerProvider: MeaningfulString,
|
||||
workerToolCallMode: SmokeToolCallMode,
|
||||
});
|
||||
export type SmokeRoleSelectionInput = typeof SmokeRoleSelectionInput.Type;
|
||||
|
||||
export const SmokeModelRole = Schema.Struct({
|
||||
model: MeaningfulString,
|
||||
provider: MeaningfulString,
|
||||
role: SmokeRole,
|
||||
toolCallMode: Schema.Literal("serial"),
|
||||
});
|
||||
export type SmokeModelRole = typeof SmokeModelRole.Type;
|
||||
|
||||
export const SmokeModelRoles = Schema.Struct({
|
||||
plannerReviewer: SmokeModelRole,
|
||||
worker: SmokeModelRole,
|
||||
});
|
||||
export type SmokeModelRoles = typeof SmokeModelRoles.Type;
|
||||
|
||||
export const SmokeContractReason = Schema.Literals([
|
||||
"InvalidInput",
|
||||
"InvalidModelRole",
|
||||
"InvalidTransition",
|
||||
"InvalidModelResponse",
|
||||
"InvalidReport",
|
||||
]);
|
||||
export type SmokeContractReason = typeof SmokeContractReason.Type;
|
||||
|
||||
export class SmokeContractError extends Schema.TaggedErrorClass<SmokeContractError>()(
|
||||
"SmokeContractError",
|
||||
{
|
||||
message: Schema.String,
|
||||
reason: SmokeContractReason,
|
||||
}
|
||||
) {}
|
||||
|
||||
export const selectSmokeModelRoles = Effect.fn("Smoke.selectModelRoles")(
|
||||
function* selectSmokeModelRoles(input: unknown) {
|
||||
const selection: SmokeRoleSelectionInput =
|
||||
yield* Schema.decodeUnknownEffect(SmokeRoleSelectionInput)(input).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new SmokeContractError({
|
||||
message: cause.message,
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (selection.workerModel !== "minimax-m3") {
|
||||
return yield* Effect.fail(
|
||||
new SmokeContractError({
|
||||
message: "The smoke worker must use minimax-m3",
|
||||
reason: "InvalidModelRole",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
if (selection.plannerReviewerModel !== "glm-5.2") {
|
||||
return yield* Effect.fail(
|
||||
new SmokeContractError({
|
||||
message: "The smoke planner/reviewer must use glm-5.2",
|
||||
reason: "InvalidModelRole",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
plannerReviewer: {
|
||||
model: selection.plannerReviewerModel,
|
||||
provider: selection.plannerReviewerProvider,
|
||||
role: "planner-reviewer" as const,
|
||||
toolCallMode: "serial" as const,
|
||||
},
|
||||
worker: {
|
||||
model: selection.workerModel,
|
||||
provider: selection.workerProvider,
|
||||
role: "worker" as const,
|
||||
toolCallMode: "serial" as const,
|
||||
},
|
||||
} satisfies SmokeModelRoles;
|
||||
}
|
||||
);
|
||||
|
||||
export const SmokePlan = Schema.Struct({
|
||||
acceptanceCriteria: Schema.NonEmptyArray(MeaningfulString),
|
||||
nonGoals: Schema.Array(MeaningfulString),
|
||||
steps: Schema.NonEmptyArray(MeaningfulString),
|
||||
summary: MeaningfulString,
|
||||
});
|
||||
export type SmokePlan = typeof SmokePlan.Type;
|
||||
|
||||
export const SmokeWorkerReport = Schema.Struct({
|
||||
status: Schema.Literals(["completed", "blocked", "failed"]),
|
||||
summary: MeaningfulString,
|
||||
});
|
||||
export type SmokeWorkerReport = typeof SmokeWorkerReport.Type;
|
||||
|
||||
export const SmokeReview = Schema.Struct({
|
||||
approved: Schema.Boolean,
|
||||
findings: Schema.Array(MeaningfulString),
|
||||
summary: MeaningfulString,
|
||||
});
|
||||
export type SmokeReview = typeof SmokeReview.Type;
|
||||
|
||||
export const SmokeChatResponse = Schema.Struct({
|
||||
choices: Schema.NonEmptyArray(
|
||||
Schema.Struct({
|
||||
message: Schema.Struct({ content: Schema.String }),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export const SmokeModelCatalog = Schema.Struct({
|
||||
data: Schema.Array(Schema.Struct({ id: MeaningfulString })),
|
||||
});
|
||||
export type SmokeModelCatalog = typeof SmokeModelCatalog.Type;
|
||||
|
||||
export const decodeSmokeModelCatalog = Effect.fn("Smoke.decodeModelCatalog")(
|
||||
function* decodeSmokeModelCatalog(input: unknown) {
|
||||
return yield* Schema.decodeUnknownEffect(SmokeModelCatalog)(input).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new SmokeContractError({
|
||||
message: cause.message,
|
||||
reason: "InvalidModelResponse",
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const decodeSmokeChatText = Effect.fn("Smoke.decodeChatText")(
|
||||
function* decodeSmokeChatText(input: unknown) {
|
||||
const response = yield* Schema.decodeUnknownEffect(SmokeChatResponse)(
|
||||
input
|
||||
).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new SmokeContractError({
|
||||
message: cause.message,
|
||||
reason: "InvalidModelResponse",
|
||||
})
|
||||
)
|
||||
);
|
||||
const text = response.choices[0].message.content;
|
||||
if (text.trim().length === 0) {
|
||||
return yield* Effect.fail(
|
||||
new SmokeContractError({
|
||||
message: "The model returned an empty response",
|
||||
reason: "InvalidModelResponse",
|
||||
})
|
||||
);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
);
|
||||
|
||||
export const decodeSmokePlan = Effect.fn("Smoke.decodePlan")(
|
||||
function* decodeSmokePlan(input: unknown) {
|
||||
return yield* Schema.decodeUnknownEffect(SmokePlan)(input).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new SmokeContractError({
|
||||
message: cause.message,
|
||||
reason: "InvalidReport",
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const decodeSmokeWorkerReport = Effect.fn("Smoke.decodeWorkerReport")(
|
||||
function* decodeSmokeWorkerReport(input: unknown) {
|
||||
return yield* Schema.decodeUnknownEffect(SmokeWorkerReport)(input).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new SmokeContractError({
|
||||
message: cause.message,
|
||||
reason: "InvalidReport",
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const decodeSmokeReview = Effect.fn("Smoke.decodeReview")(
|
||||
function* decodeSmokeReview(input: unknown) {
|
||||
return yield* Schema.decodeUnknownEffect(SmokeReview)(input).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new SmokeContractError({
|
||||
message: cause.message,
|
||||
reason: "InvalidReport",
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const SmokeCapabilityCheck = Schema.Struct({
|
||||
id: MeaningfulString,
|
||||
message: MeaningfulString,
|
||||
passed: Schema.Boolean,
|
||||
});
|
||||
export type SmokeCapabilityCheck = typeof SmokeCapabilityCheck.Type;
|
||||
|
||||
export const validateSmokeCapabilities = Effect.fn(
|
||||
"Smoke.validateCapabilities"
|
||||
)(function* validateSmokeCapabilities(input: unknown) {
|
||||
const checks = yield* Schema.decodeUnknownEffect(
|
||||
Schema.NonEmptyArray(SmokeCapabilityCheck)
|
||||
)(input).pipe(
|
||||
Effect.mapError(
|
||||
(cause) =>
|
||||
new SmokeContractError({
|
||||
message: cause.message,
|
||||
reason: "InvalidInput",
|
||||
})
|
||||
)
|
||||
);
|
||||
const failed = checks.filter((check) => !check.passed);
|
||||
if (failed.length > 0) {
|
||||
return yield* Effect.fail(
|
||||
new SmokeContractError({
|
||||
message: failed
|
||||
.map((check) => `${check.id}: ${check.message}`)
|
||||
.join("; "),
|
||||
reason: "InvalidReport",
|
||||
})
|
||||
);
|
||||
}
|
||||
return checks;
|
||||
});
|
||||
|
||||
export const SmokeLoopState = Schema.Struct({
|
||||
history: Schema.Array(SmokePhase),
|
||||
lastError: Schema.NullOr(Schema.String),
|
||||
phase: SmokePhase,
|
||||
});
|
||||
export type SmokeLoopState = typeof SmokeLoopState.Type;
|
||||
|
||||
export const initialSmokeLoopState = (): SmokeLoopState => ({
|
||||
history: ["preflight"],
|
||||
lastError: null,
|
||||
phase: "preflight",
|
||||
});
|
||||
|
||||
const nextPhase: Readonly<
|
||||
Record<SmokePhase, Partial<Record<SmokeTransition, SmokePhase>>>
|
||||
> = {
|
||||
completed: {},
|
||||
failed: {},
|
||||
planning: {
|
||||
"plan-validated": "worker-running",
|
||||
"runtime-failed": "failed",
|
||||
},
|
||||
preflight: {
|
||||
"contract-failed": "failed",
|
||||
"preflight-passed": "planning",
|
||||
"runtime-failed": "failed",
|
||||
},
|
||||
reviewing: {
|
||||
"contract-failed": "failed",
|
||||
"review-approved": "completed",
|
||||
"runtime-failed": "failed",
|
||||
},
|
||||
"worker-running": {
|
||||
"contract-failed": "failed",
|
||||
"runtime-failed": "failed",
|
||||
"worker-succeeded": "reviewing",
|
||||
},
|
||||
};
|
||||
|
||||
export const advanceSmokeLoop = Effect.fn("Smoke.advanceLoop")(
|
||||
function* advanceSmokeLoop(
|
||||
state: SmokeLoopState,
|
||||
transition: SmokeTransition,
|
||||
error?: string
|
||||
) {
|
||||
const phase = nextPhase[state.phase][transition];
|
||||
if (phase === undefined) {
|
||||
return yield* Effect.fail(
|
||||
new SmokeContractError({
|
||||
message: `Cannot apply ${transition} while smoke loop is ${state.phase}`,
|
||||
reason: "InvalidTransition",
|
||||
})
|
||||
);
|
||||
}
|
||||
return {
|
||||
history: [...state.history, phase],
|
||||
lastError: phase === "failed" ? (error ?? "Smoke loop failed") : null,
|
||||
phase,
|
||||
} satisfies SmokeLoopState;
|
||||
}
|
||||
);
|
||||
|
||||
export const redactSmokeText = (
|
||||
text: string,
|
||||
secrets: readonly string[]
|
||||
): string => {
|
||||
let redacted = text;
|
||||
for (const secret of secrets) {
|
||||
if (secret.length > 0) {
|
||||
redacted = redacted.replaceAll(secret, "[REDACTED]");
|
||||
}
|
||||
}
|
||||
return redacted;
|
||||
};
|
||||
Reference in New Issue
Block a user