@jwilger/pi-development-system 0.1.2 → 0.3.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/extensions/development-system.ts +30 -7
- package/package.json +4 -2
- package/src/context/context-tail.ts +39 -0
- package/src/context/status.ts +7 -4
- package/src/core/departure.ts +96 -0
- package/src/core/gates.ts +42 -0
- package/src/core/git-intent.ts +275 -0
- package/src/core/redact.ts +10 -0
- package/src/core/result.ts +7 -0
- package/src/core/test-paths.ts +27 -0
- package/src/core/test-weakening.ts +68 -0
- package/src/gates/approvals.ts +101 -0
- package/src/gates/git-guard.ts +79 -0
- package/src/gates/record-departure-tool.ts +102 -0
- package/src/gates/request-approval-tool.ts +62 -0
- package/src/gates/test-guard.ts +176 -0
- package/src/jev/client.ts +127 -0
- package/src/jev/holder.ts +54 -0
- package/src/jev/models.ts +15 -0
- package/src/jev/questions/shell-intent.ts +55 -0
- package/src/jev/questions/test-change.ts +76 -0
- package/src/state/decision-log.ts +28 -0
- package/src/state/session-state.ts +8 -25
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import type {
|
|
3
|
+
ClassifierAnswer,
|
|
4
|
+
ClassifierApi,
|
|
5
|
+
ClassifierContext,
|
|
6
|
+
ClassifierModel,
|
|
7
|
+
ClassifierQuestion,
|
|
8
|
+
ClassifierResult,
|
|
9
|
+
JsonObject,
|
|
10
|
+
} from "@earendil-works/pi-ai";
|
|
11
|
+
import { err, ok, type Result } from "../core/result.ts";
|
|
12
|
+
import { splitModelRef } from "./models.ts";
|
|
13
|
+
|
|
14
|
+
export type JevAvailability = "online" | "offline" | "unknown";
|
|
15
|
+
export type JevError =
|
|
16
|
+
| { kind: "no-model" }
|
|
17
|
+
| { kind: "timeout" }
|
|
18
|
+
| { kind: "provider"; message: string }
|
|
19
|
+
| { kind: "aborted" };
|
|
20
|
+
|
|
21
|
+
type Classifier = ClassifierModel<ClassifierApi>;
|
|
22
|
+
|
|
23
|
+
/** The slice of pi's `ctx.modelRegistry` that Jev needs; tests pass a fake. */
|
|
24
|
+
export interface ClassifierRegistry {
|
|
25
|
+
findOfType(type: "classifier", provider: string, modelId: string): Classifier | undefined;
|
|
26
|
+
// biome-ignore lint/suspicious/noExplicitAny: structural bridge to the host's Model<Api> parameter
|
|
27
|
+
hasConfiguredAuth(model: any): boolean;
|
|
28
|
+
classify(
|
|
29
|
+
model: Classifier,
|
|
30
|
+
context: ClassifierContext,
|
|
31
|
+
options?: { signal?: AbortSignal },
|
|
32
|
+
): Promise<ClassifierResult>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type JevOptions = {
|
|
36
|
+
registry: ClassifierRegistry;
|
|
37
|
+
candidates: readonly string[];
|
|
38
|
+
timeoutMs: number;
|
|
39
|
+
cache: Map<string, ClassifierResult>;
|
|
40
|
+
now(): number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface Jev {
|
|
44
|
+
ask(
|
|
45
|
+
state: JsonObject,
|
|
46
|
+
questions: Record<string, ClassifierQuestion>,
|
|
47
|
+
): Promise<Result<Record<string, ClassifierAnswer>, JevError>>;
|
|
48
|
+
availability(): JevAvailability;
|
|
49
|
+
model(): string | undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** First candidate `provider/id` that exists in the registry AND has configured auth. */
|
|
53
|
+
export function resolveJevModel(
|
|
54
|
+
registry: ClassifierRegistry,
|
|
55
|
+
candidates: readonly string[],
|
|
56
|
+
): Classifier | undefined {
|
|
57
|
+
for (const ref of candidates) {
|
|
58
|
+
const parts = splitModelRef(ref);
|
|
59
|
+
if (parts === undefined) continue;
|
|
60
|
+
const model = registry.findOfType("classifier", parts.provider, parts.id);
|
|
61
|
+
if (model !== undefined && registry.hasConfiguredAuth(model)) return model;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const hashOf = (state: JsonObject, questions: Record<string, ClassifierQuestion>): string =>
|
|
67
|
+
createHash("sha256").update(JSON.stringify({ state, questions })).digest("hex");
|
|
68
|
+
|
|
69
|
+
/** Wraps the host classifier: model resolution, timeout, caching and typed errors. Never rejects. */
|
|
70
|
+
export function createJev(opts: JevOptions): Jev {
|
|
71
|
+
let availability: JevAvailability = "unknown";
|
|
72
|
+
let current: string | undefined;
|
|
73
|
+
|
|
74
|
+
const run = async (
|
|
75
|
+
model: Classifier,
|
|
76
|
+
context: ClassifierContext,
|
|
77
|
+
): Promise<Result<ClassifierResult, JevError>> => {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
80
|
+
const timeout = new Promise<"timeout">((resolve) => {
|
|
81
|
+
timer = setTimeout(() => {
|
|
82
|
+
controller.abort();
|
|
83
|
+
resolve("timeout");
|
|
84
|
+
}, opts.timeoutMs);
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
const outcome = await Promise.race([
|
|
88
|
+
opts.registry.classify(model, context, { signal: controller.signal }),
|
|
89
|
+
timeout,
|
|
90
|
+
]);
|
|
91
|
+
if (outcome === "timeout") return err({ kind: "timeout" });
|
|
92
|
+
if (outcome.stopReason === "aborted") return err({ kind: "aborted" });
|
|
93
|
+
if (outcome.stopReason === "error")
|
|
94
|
+
return err({ kind: "provider", message: outcome.errorMessage ?? "classifier error" });
|
|
95
|
+
return ok(outcome);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return err({ kind: "provider", message: e instanceof Error ? e.message : String(e) });
|
|
98
|
+
} finally {
|
|
99
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
async ask(state, questions) {
|
|
105
|
+
const model = resolveJevModel(opts.registry, opts.candidates);
|
|
106
|
+
if (model === undefined) {
|
|
107
|
+
availability = "offline";
|
|
108
|
+
current = undefined;
|
|
109
|
+
return err({ kind: "no-model" });
|
|
110
|
+
}
|
|
111
|
+
current = `${model.provider}/${model.id}`;
|
|
112
|
+
const key = hashOf(state, questions);
|
|
113
|
+
const cached = opts.cache.get(key);
|
|
114
|
+
if (cached !== undefined) return ok(cached.answers);
|
|
115
|
+
const result = await run(model, { state, questions });
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
availability = result.error.kind === "aborted" ? availability : "offline";
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
availability = "online";
|
|
121
|
+
opts.cache.set(key, result.value);
|
|
122
|
+
return ok(result.value.answers);
|
|
123
|
+
},
|
|
124
|
+
availability: () => availability,
|
|
125
|
+
model: () => current,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ClassifierResult } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import {
|
|
4
|
+
type ClassifierRegistry,
|
|
5
|
+
createJev,
|
|
6
|
+
type Jev,
|
|
7
|
+
type JevAvailability,
|
|
8
|
+
resolveJevModel,
|
|
9
|
+
} from "./client.ts";
|
|
10
|
+
import { DEFAULT_JEV_CANDIDATES } from "./models.ts";
|
|
11
|
+
|
|
12
|
+
export type JevHolder = {
|
|
13
|
+
/** The Jev bound to this context's model registry (one per registry, shared cache). */
|
|
14
|
+
forContext(ctx: Pick<ExtensionContext, "modelRegistry">): Jev;
|
|
15
|
+
/** Cheap, offline probe of whether a Jev credential is configured. */
|
|
16
|
+
probe(ctx: Pick<ExtensionContext, "modelRegistry">): {
|
|
17
|
+
availability: JevAvailability;
|
|
18
|
+
model: string | undefined;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function createJevHolder(opts?: {
|
|
23
|
+
candidates?: readonly string[];
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}): JevHolder {
|
|
27
|
+
const candidates = opts?.candidates ?? DEFAULT_JEV_CANDIDATES;
|
|
28
|
+
const cache = new Map<string, ClassifierResult>();
|
|
29
|
+
const bound = new WeakMap<object, Jev>();
|
|
30
|
+
const registryOf = (ctx: Pick<ExtensionContext, "modelRegistry">): ClassifierRegistry =>
|
|
31
|
+
ctx.modelRegistry;
|
|
32
|
+
return {
|
|
33
|
+
forContext(ctx) {
|
|
34
|
+
const registry = registryOf(ctx);
|
|
35
|
+
const existing = bound.get(registry);
|
|
36
|
+
if (existing !== undefined) return existing;
|
|
37
|
+
const jev = createJev({
|
|
38
|
+
registry,
|
|
39
|
+
candidates,
|
|
40
|
+
timeoutMs: opts?.timeoutMs ?? 15_000,
|
|
41
|
+
cache,
|
|
42
|
+
now: opts?.now ?? Date.now,
|
|
43
|
+
});
|
|
44
|
+
bound.set(registry, jev);
|
|
45
|
+
return jev;
|
|
46
|
+
},
|
|
47
|
+
probe(ctx) {
|
|
48
|
+
const model = resolveJevModel(registryOf(ctx), candidates);
|
|
49
|
+
return model === undefined
|
|
50
|
+
? { availability: "offline", model: undefined }
|
|
51
|
+
: { availability: "online", model: `${model.provider}/${model.id}` };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Default Jev classifier candidates, tried in order; overridable by `[models] jev`. */
|
|
2
|
+
export const DEFAULT_JEV_CANDIDATES: readonly string[] = [
|
|
3
|
+
"typesafe/jev-latest",
|
|
4
|
+
"openrouter/typesafe/jev-latest",
|
|
5
|
+
"opencode/jev-1.13",
|
|
6
|
+
"cloudflare-workers-ai/typesafe/jev",
|
|
7
|
+
"vercel-ai-gateway/typesafe-ai/jev",
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
/** Splits `provider/id` on the first slash (ids may themselves contain slashes). */
|
|
11
|
+
export function splitModelRef(ref: string): { provider: string; id: string } | undefined {
|
|
12
|
+
const at = ref.indexOf("/");
|
|
13
|
+
if (at <= 0 || at === ref.length - 1) return undefined;
|
|
14
|
+
return { provider: ref.slice(0, at), id: ref.slice(at + 1) };
|
|
15
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { GitIntent } from "../../core/git-intent.ts";
|
|
3
|
+
import { err, ok, type Result } from "../../core/result.ts";
|
|
4
|
+
import type { Jev, JevError } from "../client.ts";
|
|
5
|
+
|
|
6
|
+
export const SHELL_INTENT_THRESHOLD = 0.6;
|
|
7
|
+
|
|
8
|
+
export const SHELL_INTENT_QUESTION: ClassifierChoiceQuestion = {
|
|
9
|
+
type: "choice",
|
|
10
|
+
instructions:
|
|
11
|
+
"A coding agent is about to run the shell command in `command`. Which irreversible git operation, if any, would running it perform? Consider wrappers, subshells, variables, pipes and scripts. Choose `ordinary` when it performs none of them (including commands that only mention git text).",
|
|
12
|
+
criteria: {
|
|
13
|
+
"force-push":
|
|
14
|
+
"Pushes with --force, --force-with-lease, --mirror, or a +refspec, overwriting remote history",
|
|
15
|
+
"branch-delete-remote": "Deletes a branch or tag on a remote (push --delete, push :ref)",
|
|
16
|
+
"history-rewrite": "Rewrites local history: commit --amend, rebase, filter-branch, filter-repo",
|
|
17
|
+
"destructive-reset":
|
|
18
|
+
"Discards work irrecoverably: reset --hard, clean -fd, checkout -- . over uncommitted changes",
|
|
19
|
+
"no-verify":
|
|
20
|
+
"Skips commit/push hooks: --no-verify, -n on commit, core.hooksPath override, LEFTHOOK=0 / HUSKY=0",
|
|
21
|
+
ordinary: "None of the above; read-only or reversible work",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const INTENTS: readonly GitIntent[] = [
|
|
26
|
+
"force-push",
|
|
27
|
+
"branch-delete-remote",
|
|
28
|
+
"history-rewrite",
|
|
29
|
+
"destructive-reset",
|
|
30
|
+
"no-verify",
|
|
31
|
+
"ordinary",
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const isIntent = (value: string): value is GitIntent => INTENTS.some((i) => i === value);
|
|
35
|
+
|
|
36
|
+
/** Pure policy: a confident Jev choice maps to its intent; low confidence or an unknown label → `unknown`. */
|
|
37
|
+
export function intentFromChoice(choice: string, confidence: number): GitIntent {
|
|
38
|
+
if (confidence < SHELL_INTENT_THRESHOLD) return "unknown";
|
|
39
|
+
return isIntent(choice) ? choice : "unknown";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function judgeShellIntent(
|
|
43
|
+
jev: Jev,
|
|
44
|
+
command: string,
|
|
45
|
+
): Promise<Result<{ intent: GitIntent; confidence: number }, JevError>> {
|
|
46
|
+
const asked = await jev.ask({ command }, { intent: SHELL_INTENT_QUESTION });
|
|
47
|
+
if (!asked.ok) return asked;
|
|
48
|
+
const answer = asked.value.intent;
|
|
49
|
+
if (answer?.type !== "choice")
|
|
50
|
+
return err({ kind: "provider", message: "missing shell-intent answer" });
|
|
51
|
+
return ok({
|
|
52
|
+
intent: intentFromChoice(answer.choice, answer.confidence),
|
|
53
|
+
confidence: answer.confidence,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ClassifierBoolQuestion, ClassifierChoiceQuestion } from "@earendil-works/pi-ai";
|
|
2
|
+
import { err, ok, type Result } from "../../core/result.ts";
|
|
3
|
+
import type { Jev, JevError } from "../client.ts";
|
|
4
|
+
|
|
5
|
+
export const WEAKENS_THRESHOLD = 0.7;
|
|
6
|
+
export const MOTIVE_THRESHOLD = 0.6;
|
|
7
|
+
|
|
8
|
+
export type TestChangeMotive = "requirement-change" | "gate-gaming" | "refactor" | "unclear";
|
|
9
|
+
export type TestChangeInput = {
|
|
10
|
+
path: string;
|
|
11
|
+
before?: string;
|
|
12
|
+
after?: string;
|
|
13
|
+
recentFailure?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const WEAKENS_QUESTION: ClassifierBoolQuestion = {
|
|
17
|
+
type: "bool",
|
|
18
|
+
instructions:
|
|
19
|
+
"Does the change to the test file in `path` (from `before` to `after`; `after` absent means deleted) weaken what the tests verify — removing or loosening assertions, skipping or deleting tests, widening expected values, or catching and ignoring failures?",
|
|
20
|
+
criteria: {
|
|
21
|
+
true: "The tests verify less behaviour afterwards (assertions removed or loosened, tests skipped, disabled or deleted)",
|
|
22
|
+
false:
|
|
23
|
+
"The tests verify the same or more behaviour (added tests, stronger assertions, renames, pure refactors)",
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const MOTIVE_QUESTION: ClassifierChoiceQuestion = {
|
|
28
|
+
type: "choice",
|
|
29
|
+
instructions:
|
|
30
|
+
"Why is the test file in `path` being changed from `before` to `after`? `recentFailure` is the most recent failing output, if any. Choose `gate-gaming` only when the change makes a failing check pass by weakening the check instead of fixing the code.",
|
|
31
|
+
criteria: {
|
|
32
|
+
"requirement-change":
|
|
33
|
+
"The behaviour under test was deliberately changed, so the test must change with it",
|
|
34
|
+
"gate-gaming": "A failing test is being weakened, skipped or deleted to get a green result",
|
|
35
|
+
refactor: "Structure of the test changes with no change in what is verified",
|
|
36
|
+
unclear: "Cannot tell from the evidence",
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const MOTIVES: readonly TestChangeMotive[] = [
|
|
41
|
+
"requirement-change",
|
|
42
|
+
"gate-gaming",
|
|
43
|
+
"refactor",
|
|
44
|
+
"unclear",
|
|
45
|
+
];
|
|
46
|
+
const isMotive = (value: string): value is TestChangeMotive => MOTIVES.some((m) => m === value);
|
|
47
|
+
|
|
48
|
+
/** Pure policy: low confidence or an unexpected label → `unclear`. */
|
|
49
|
+
export const motiveFromChoice = (choice: string, confidence: number): TestChangeMotive =>
|
|
50
|
+
confidence >= MOTIVE_THRESHOLD && isMotive(choice) ? choice : "unclear";
|
|
51
|
+
|
|
52
|
+
export async function judgeTestChange(
|
|
53
|
+
jev: Jev,
|
|
54
|
+
input: TestChangeInput,
|
|
55
|
+
): Promise<Result<{ weakens: number; motive: TestChangeMotive; confidence: number }, JevError>> {
|
|
56
|
+
const clip = (text: string | undefined): string => (text ?? "").slice(0, 6000);
|
|
57
|
+
const state = {
|
|
58
|
+
path: input.path,
|
|
59
|
+
before: clip(input.before),
|
|
60
|
+
after: input.after === undefined ? "" : clip(input.after),
|
|
61
|
+
deleted: input.after === undefined,
|
|
62
|
+
recentFailure: clip(input.recentFailure),
|
|
63
|
+
};
|
|
64
|
+
const asked = await jev.ask(state, { weakens: WEAKENS_QUESTION, motive: MOTIVE_QUESTION });
|
|
65
|
+
if (!asked.ok) return asked;
|
|
66
|
+
const weakens = asked.value.weakens;
|
|
67
|
+
const motive = asked.value.motive;
|
|
68
|
+
if (weakens?.type !== "bool" || motive?.type !== "choice") {
|
|
69
|
+
return err({ kind: "provider", message: "missing test-change answers" });
|
|
70
|
+
}
|
|
71
|
+
return ok({
|
|
72
|
+
weakens: weakens.probability,
|
|
73
|
+
motive: motiveFromChoice(motive.choice, motive.confidence),
|
|
74
|
+
confidence: motive.confidence,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { appendFile, mkdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { type Departure, renderDepartureMarkdown } from "../core/departure.ts";
|
|
4
|
+
|
|
5
|
+
const exists = async (path: string): Promise<boolean> =>
|
|
6
|
+
stat(path).then(
|
|
7
|
+
() => true,
|
|
8
|
+
() => false,
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
/** Appends a departure to `docs/decisions/YYYY-MM.md` (UTC month of `now`), creating it with a header. */
|
|
12
|
+
export async function appendDecision(
|
|
13
|
+
repoRoot: string,
|
|
14
|
+
d: Departure,
|
|
15
|
+
now: Date,
|
|
16
|
+
): Promise<{ path: string }> {
|
|
17
|
+
const month = now.toISOString().slice(0, 7);
|
|
18
|
+
const dir = join(repoRoot, "docs", "decisions");
|
|
19
|
+
const path = join(dir, `${month}.md`);
|
|
20
|
+
await mkdir(dir, { recursive: true });
|
|
21
|
+
const entry = renderDepartureMarkdown(d);
|
|
22
|
+
if (await exists(path)) {
|
|
23
|
+
await appendFile(path, `\n${entry}`);
|
|
24
|
+
} else {
|
|
25
|
+
await appendFile(path, `# Decisions — ${month}\n\n${entry}`);
|
|
26
|
+
}
|
|
27
|
+
return { path };
|
|
28
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { parseDeparture } from "../core/departure.ts";
|
|
2
3
|
import {
|
|
3
4
|
type Departure,
|
|
4
5
|
type DevsysState,
|
|
@@ -28,36 +29,18 @@ const JEV: ReadonlyArray<JevStatus> = ["online", "offline", "unknown"];
|
|
|
28
29
|
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
29
30
|
typeof v === "object" && v !== null && !Array.isArray(v);
|
|
30
31
|
|
|
31
|
-
const isString = (v: unknown): v is string => typeof v === "string";
|
|
32
|
-
|
|
33
|
-
/** Structural check for a persisted Departure; I1.1's parseDeparture should supersede it. */
|
|
34
|
-
function isDeparture(v: unknown): v is Departure {
|
|
35
|
-
if (!isRecord(v)) return false;
|
|
36
|
-
const scope = v["scope"];
|
|
37
|
-
const scopeOk =
|
|
38
|
-
isRecord(scope) &&
|
|
39
|
-
(scope["kind"] === "session" ||
|
|
40
|
-
(scope["kind"] === "slice" && isString(scope["slice"])) ||
|
|
41
|
-
(scope["kind"] === "once" && isString(scope["toolCallId"])));
|
|
42
|
-
return (
|
|
43
|
-
scopeOk &&
|
|
44
|
-
["id", "gate", "default", "chosen", "why", "costIfWrong", "recordedAt"].every((k) =>
|
|
45
|
-
isString(v[k]),
|
|
46
|
-
) &&
|
|
47
|
-
(v["tier"] === "soft" || v["tier"] === "hard") &&
|
|
48
|
-
(v["approver"] === "agent" || v["approver"] === "user") &&
|
|
49
|
-
(v["revisitWhen"] === undefined || isString(v["revisitWhen"]))
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
32
|
/** Boundary parse for persisted state: the only place state is cast from `unknown`. */
|
|
54
33
|
export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
55
34
|
if (!isRecord(input)) return parseError("devsys state must be an object");
|
|
56
35
|
const { phase, sizing, activeSlice, openDepartures, jev, lastPushAt } = input;
|
|
57
36
|
if (!PHASES.includes(phase as Phase)) return parseError(`unknown phase: ${String(phase)}`);
|
|
58
37
|
if (!JEV.includes(jev as JevStatus)) return parseError(`unknown jev status: ${String(jev)}`);
|
|
59
|
-
if (!Array.isArray(openDepartures)
|
|
60
|
-
|
|
38
|
+
if (!Array.isArray(openDepartures)) return parseError("openDepartures must be an array");
|
|
39
|
+
const departures: Departure[] = [];
|
|
40
|
+
for (const raw of openDepartures) {
|
|
41
|
+
const departure = parseDeparture(raw);
|
|
42
|
+
if (isParseError(departure)) return departure;
|
|
43
|
+
departures.push(departure);
|
|
61
44
|
}
|
|
62
45
|
if (sizing !== undefined && !SIZINGS.includes(sizing as Sizing)) {
|
|
63
46
|
return parseError(`unknown sizing: ${String(sizing)}`);
|
|
@@ -71,7 +54,7 @@ export function parseDevsysState(input: unknown): DevsysState | ParseError {
|
|
|
71
54
|
return {
|
|
72
55
|
phase: phase as Phase,
|
|
73
56
|
jev: jev as JevStatus,
|
|
74
|
-
openDepartures:
|
|
57
|
+
openDepartures: departures,
|
|
75
58
|
...(sizing !== undefined ? { sizing: sizing as Sizing } : {}),
|
|
76
59
|
...(activeSlice !== undefined ? { activeSlice: activeSlice as SliceRef } : {}),
|
|
77
60
|
...(lastPushAt !== undefined ? { lastPushAt } : {}),
|