@nebutra/agent-runtime 0.2.1 → 2.0.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/LICENSE +21 -676
- package/README.md +33 -10
- package/dist/adapters/index.d.ts +24 -5
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/index.js.map +1 -1
- package/dist/{chunk-KCNN4QUQ.js → chunk-5UGIUYJR.js} +4 -4
- package/dist/chunk-7ELA4SWE.js +73 -0
- package/dist/chunk-7ELA4SWE.js.map +1 -0
- package/dist/chunk-Q62VKHIT.js +178 -0
- package/dist/chunk-Q62VKHIT.js.map +1 -0
- package/dist/chunk-VXILZAXK.js +541 -0
- package/dist/chunk-VXILZAXK.js.map +1 -0
- package/dist/command-exec.d.ts +45 -0
- package/dist/command-exec.js +15 -0
- package/dist/command-exec.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +81 -28
- package/dist/index.js.map +1 -1
- package/dist/orchestration.d.ts +42 -1
- package/dist/orchestration.js +7 -3
- package/dist/pulsar.js +2 -2
- package/dist/sandbox-DfcRttXt.d.ts +219 -0
- package/dist/sandbox.d.ts +2 -64
- package/dist/sandbox.js +25 -3
- package/package.json +81 -30
- package/.turbo/turbo-build.log +0 -130
- package/.turbo/turbo-test.log +0 -47
- package/.turbo/turbo-typecheck.log +0 -4
- package/CHANGELOG.md +0 -250
- package/dist/chunk-MUF7ZZTO.js +0 -57
- package/dist/chunk-MUF7ZZTO.js.map +0 -1
- package/dist/chunk-MX2WL43P.js +0 -90
- package/dist/chunk-MX2WL43P.js.map +0 -1
- package/examples/pulsar-quickstart.ts +0 -35
- package/examples/resume-branch.ts +0 -45
- package/examples/subagent-fanout.ts +0 -20
- package/src/adapters/dispatcher-sse.test.ts +0 -218
- package/src/adapters/dispatcher-sse.ts +0 -222
- package/src/adapters/index.ts +0 -18
- package/src/adapters/mcp-catalog.test.ts +0 -213
- package/src/adapters/mcp-catalog.ts +0 -188
- package/src/adapters/prisma-rollout.test.ts +0 -153
- package/src/adapters/prisma-rollout.ts +0 -104
- package/src/agent-runtime.test.ts +0 -176
- package/src/artifact-stream.test.ts +0 -330
- package/src/artifact-stream.ts +0 -453
- package/src/channel-gateway.test.ts +0 -432
- package/src/channel-gateway.ts +0 -357
- package/src/cli.ts +0 -49
- package/src/code-review.test.ts +0 -501
- package/src/code-review.ts +0 -495
- package/src/command-suggestions.test.ts +0 -251
- package/src/command-suggestions.ts +0 -338
- package/src/commands.test.ts +0 -184
- package/src/commands.ts +0 -140
- package/src/commit-message.test.ts +0 -249
- package/src/commit-message.ts +0 -180
- package/src/context-compaction.test.ts +0 -522
- package/src/context-compaction.ts +0 -438
- package/src/definitions.test.ts +0 -78
- package/src/definitions.ts +0 -190
- package/src/deployment-status.test.ts +0 -215
- package/src/deployment-status.ts +0 -227
- package/src/design-context.test.ts +0 -195
- package/src/design-context.ts +0 -198
- package/src/dispatcher.test.ts +0 -234
- package/src/dispatcher.ts +0 -189
- package/src/durable-turn.test.ts +0 -209
- package/src/durable-turn.ts +0 -135
- package/src/edit-planner.test.ts +0 -204
- package/src/edit-planner.ts +0 -325
- package/src/fuzzy-match.test.ts +0 -311
- package/src/fuzzy-match.ts +0 -444
- package/src/hook-pipeline.test.ts +0 -279
- package/src/hook-pipeline.ts +0 -373
- package/src/inbound-admission.test.ts +0 -394
- package/src/inbound-admission.ts +0 -246
- package/src/index.ts +0 -49
- package/src/loop.test.ts +0 -161
- package/src/loop.ts +0 -211
- package/src/mcp-bridge.test.ts +0 -165
- package/src/mcp-bridge.ts +0 -81
- package/src/memory-provider.test.ts +0 -232
- package/src/memory-provider.ts +0 -257
- package/src/model.ts +0 -168
- package/src/orchestration.test.ts +0 -53
- package/src/orchestration.ts +0 -146
- package/src/permission-ruleset.test.ts +0 -301
- package/src/permission-ruleset.ts +0 -1
- package/src/policy.ts +0 -151
- package/src/project-repo.test.ts +0 -232
- package/src/project-repo.ts +0 -311
- package/src/protocol.ts +0 -159
- package/src/pulsar.test.ts +0 -156
- package/src/pulsar.ts +0 -322
- package/src/rollout-store-persistent.test.ts +0 -217
- package/src/rollout-store-persistent.ts +0 -166
- package/src/rollout.ts +0 -150
- package/src/sandbox.ts +0 -113
- package/src/session-share.test.ts +0 -360
- package/src/session-share.ts +0 -310
- package/src/skill-distillation.test.ts +0 -177
- package/src/skill-distillation.ts +0 -369
- package/src/skills.test.ts +0 -277
- package/src/skills.ts +0 -277
- package/src/subagents.test.ts +0 -290
- package/src/subagents.ts +0 -332
- package/src/tools.test.ts +0 -12
- package/src/tools.ts +0 -129
- package/src/workbench.test.ts +0 -0
- package/src/workbench.ts +0 -0
- package/tsconfig.json +0 -12
- package/tsup.config.ts +0 -36
- /package/dist/{chunk-KCNN4QUQ.js.map → chunk-5UGIUYJR.js.map} +0 -0
package/src/definitions.ts
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared definition kernel (WRAP — Claude-Code-class harness, the delta).
|
|
3
|
-
*
|
|
4
|
-
* Faithful re-expression of the source harness's layered-merge + precedence +
|
|
5
|
-
* dual-gate resolver and its frontmatter→record parser. One implementation
|
|
6
|
-
* backs the command registry, the skill catalog, and the subagent-definition
|
|
7
|
-
* registry (the source uses the same loader for all three).
|
|
8
|
-
*
|
|
9
|
-
* Multi-tenant: every resolution is keyed by `tenantId`; tiers from one tenant
|
|
10
|
-
* never merge into another. Pure data/logic — no FS scan, no shell, no TUI.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { z } from "zod";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Source-tier precedence, low → high authority. A later tier overrides an
|
|
17
|
-
* earlier one on slug collision (mirrors the upstream merge order:
|
|
18
|
-
* bundled → builtin → workspace → workflow → plugin → dynamic, with
|
|
19
|
-
* org policy as the final, non-overridable authority).
|
|
20
|
-
*/
|
|
21
|
-
export const SOURCE_TIERS = [
|
|
22
|
-
"bundled",
|
|
23
|
-
"builtin",
|
|
24
|
-
"workspace",
|
|
25
|
-
"workflow",
|
|
26
|
-
"plugin",
|
|
27
|
-
"dynamic",
|
|
28
|
-
"policy",
|
|
29
|
-
] as const;
|
|
30
|
-
export type SourceTier = (typeof SOURCE_TIERS)[number];
|
|
31
|
-
|
|
32
|
-
const tierRank = new Map<SourceTier, number>(SOURCE_TIERS.map((t, i) => [t, i]));
|
|
33
|
-
|
|
34
|
-
/** Frontmatter shared by skills / commands / subagents (faithful subset). */
|
|
35
|
-
export const frontmatterSchema = z.object({
|
|
36
|
-
name: z.string().min(1),
|
|
37
|
-
description: z.string().default(""),
|
|
38
|
-
/** Tool allowlist this definition self-declares. */
|
|
39
|
-
allowedTools: z.array(z.string()).default([]),
|
|
40
|
-
/** Tool denylist (subagents). */
|
|
41
|
-
disallowedTools: z.array(z.string()).default([]),
|
|
42
|
-
argumentHint: z.string().optional(),
|
|
43
|
-
argNames: z.array(z.string()).default([]),
|
|
44
|
-
whenToUse: z.string().optional(),
|
|
45
|
-
version: z.string().optional(),
|
|
46
|
-
/** "inherit" | a model alias. */
|
|
47
|
-
model: z.string().optional(),
|
|
48
|
-
effort: z.enum(["low", "medium", "high"]).optional(),
|
|
49
|
-
/** Model may auto-invoke this. */
|
|
50
|
-
modelInvocable: z.boolean().default(true),
|
|
51
|
-
/** A human may invoke this. */
|
|
52
|
-
userInvocable: z.boolean().default(true),
|
|
53
|
-
/** inline = expand into current turn; fork = isolated child. */
|
|
54
|
-
executionMode: z.enum(["inline", "fork"]).default("inline"),
|
|
55
|
-
agent: z.string().optional(),
|
|
56
|
-
/** Path-activation globs (surface only after a matching file is touched). */
|
|
57
|
-
paths: z.array(z.string()).default([]),
|
|
58
|
-
});
|
|
59
|
-
export type Frontmatter = z.infer<typeof frontmatterSchema>;
|
|
60
|
-
|
|
61
|
-
const FRONTMATTER_FENCE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
62
|
-
|
|
63
|
-
/** Aliases mapping kebab/snake frontmatter keys → schema keys. */
|
|
64
|
-
const KEY_ALIASES: Record<string, keyof Frontmatter> = {
|
|
65
|
-
"allowed-tools": "allowedTools",
|
|
66
|
-
"disallowed-tools": "disallowedTools",
|
|
67
|
-
"argument-hint": "argumentHint",
|
|
68
|
-
arguments: "argNames",
|
|
69
|
-
when_to_use: "whenToUse",
|
|
70
|
-
"disable-model-invocation": "modelInvocable", // inverted below
|
|
71
|
-
"user-invocable": "userInvocable",
|
|
72
|
-
context: "executionMode",
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
function coerceScalar(raw: string): string | boolean | string[] {
|
|
76
|
-
const v = raw.trim();
|
|
77
|
-
if (v === "true") return true;
|
|
78
|
-
if (v === "false") return false;
|
|
79
|
-
if (v.startsWith("[") && v.endsWith("]")) {
|
|
80
|
-
return v
|
|
81
|
-
.slice(1, -1)
|
|
82
|
-
.split(",")
|
|
83
|
-
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
|
|
84
|
-
.filter(Boolean);
|
|
85
|
-
}
|
|
86
|
-
return v.replace(/^["']|["']$/g, "");
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Parse a `--- … ---` frontmatter block + remaining body. Dependency-light
|
|
91
|
-
* line parser (the package only depends on zod) — sufficient for the simple
|
|
92
|
-
* key: value / list frontmatter the source uses. Throws on missing block.
|
|
93
|
-
*/
|
|
94
|
-
export function parseFrontmatter(raw: string): { frontmatter: Frontmatter; body: string } {
|
|
95
|
-
const m = FRONTMATTER_FENCE.exec(raw);
|
|
96
|
-
if (!m) throw new Error("definition is missing a '--- … ---' frontmatter block");
|
|
97
|
-
const block = m[1] ?? "";
|
|
98
|
-
const body = raw.slice(m[0].length);
|
|
99
|
-
|
|
100
|
-
const bag: Record<string, unknown> = {};
|
|
101
|
-
for (const line of block.split(/\r?\n/)) {
|
|
102
|
-
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
103
|
-
const idx = line.indexOf(":");
|
|
104
|
-
if (idx < 0) continue;
|
|
105
|
-
const rawKey = line.slice(0, idx).trim();
|
|
106
|
-
const value = coerceScalar(line.slice(idx + 1));
|
|
107
|
-
const key = KEY_ALIASES[rawKey] ?? (rawKey as keyof Frontmatter);
|
|
108
|
-
if (rawKey === "disable-model-invocation") {
|
|
109
|
-
bag.modelInvocable = value === true ? false : value === false ? true : true;
|
|
110
|
-
continue;
|
|
111
|
-
}
|
|
112
|
-
if (rawKey === "context") {
|
|
113
|
-
bag.executionMode = value === "fork" ? "fork" : "inline";
|
|
114
|
-
continue;
|
|
115
|
-
}
|
|
116
|
-
bag[key] = value;
|
|
117
|
-
}
|
|
118
|
-
return { frontmatter: frontmatterSchema.parse(bag), body };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** A resolvable definition of any kind (skill / command / subagent). */
|
|
122
|
-
export interface Definition {
|
|
123
|
-
readonly slug: string;
|
|
124
|
-
readonly tenantId: string;
|
|
125
|
-
readonly sourceTier: SourceTier;
|
|
126
|
-
readonly frontmatter: Frontmatter;
|
|
127
|
-
readonly bodyRef: string;
|
|
128
|
-
/** Entitlement gate — who may have this at all. */
|
|
129
|
-
readonly availabilityPlans?: readonly string[];
|
|
130
|
-
/** Runtime/feature toggle, recomputed per request. */
|
|
131
|
-
readonly enabled?: boolean;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export interface ResolveContext {
|
|
135
|
-
readonly tenantId: string;
|
|
136
|
-
readonly plan?: string;
|
|
137
|
-
/** Names already loaded this session (suppressed from re-listing). */
|
|
138
|
-
readonly loadedSlugs?: ReadonlySet<string>;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Tenant-scoped layered resolver. Merges definitions by slug with tier
|
|
143
|
-
* precedence and applies the dual gate (availability ∧ enabled), recomputed
|
|
144
|
-
* per call so entitlement/flag changes apply live. Cross-tenant inputs are
|
|
145
|
-
* rejected — fail closed.
|
|
146
|
-
*/
|
|
147
|
-
export class DefinitionResolver<T extends Definition = Definition> {
|
|
148
|
-
readonly #all: readonly T[];
|
|
149
|
-
|
|
150
|
-
constructor(definitions: readonly T[]) {
|
|
151
|
-
this.#all = [...definitions];
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Entitlement gate. */
|
|
155
|
-
static available(def: Definition, plan?: string): boolean {
|
|
156
|
-
if (!def.availabilityPlans || def.availabilityPlans.length === 0) return true;
|
|
157
|
-
return plan != null && def.availabilityPlans.includes(plan);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
resolve(ctx: ResolveContext): T[] {
|
|
161
|
-
if (!ctx.tenantId) throw new Error("ResolveContext.tenantId is required");
|
|
162
|
-
const winner = new Map<string, T>();
|
|
163
|
-
for (const def of this.#all) {
|
|
164
|
-
if (def.tenantId !== ctx.tenantId) continue; // tenant isolation, fail-closed
|
|
165
|
-
if (def.enabled === false) continue;
|
|
166
|
-
if (!DefinitionResolver.available(def, ctx.plan)) continue;
|
|
167
|
-
const cur = winner.get(def.slug);
|
|
168
|
-
if (!cur || tierRank.get(def.sourceTier)! >= tierRank.get(cur.sourceTier)!) {
|
|
169
|
-
winner.set(def.slug, def);
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
return [...winner.values()];
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Resolve a single slug (highest-precedence, gated). */
|
|
176
|
-
resolveOne(slug: string, ctx: ResolveContext): T | undefined {
|
|
177
|
-
return this.resolve(ctx).find((d) => d.slug === slug);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/** Substitute `${name}` / positional `$1` args + session/dir variables. */
|
|
182
|
-
export function substituteArguments(
|
|
183
|
-
template: string,
|
|
184
|
-
args: Record<string, string>,
|
|
185
|
-
variables: Record<string, string> = {},
|
|
186
|
-
): string {
|
|
187
|
-
return template.replace(/\$\{(\w+)\}/g, (_, k: string) =>
|
|
188
|
-
k in args ? args[k]! : k in variables ? variables[k]! : "",
|
|
189
|
-
);
|
|
190
|
-
}
|
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
advanceState,
|
|
4
|
-
type DeploymentCommitRef,
|
|
5
|
-
type DeploymentRecord,
|
|
6
|
-
deriveLatestStatus,
|
|
7
|
-
deriveTimelineFromCommits,
|
|
8
|
-
domainForCommit,
|
|
9
|
-
getDeploymentStatus,
|
|
10
|
-
getDeploymentTimeline,
|
|
11
|
-
} from "./deployment-status.js";
|
|
12
|
-
|
|
13
|
-
const SUFFIX = ".preview.example.test";
|
|
14
|
-
|
|
15
|
-
describe("domainForCommit", () => {
|
|
16
|
-
it("is deterministic for the same input", () => {
|
|
17
|
-
expect(domainForCommit("abc1234", SUFFIX)).toBe(domainForCommit("abc1234", SUFFIX));
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("uses the injected suffix and never a hardcoded host", () => {
|
|
21
|
-
const d = domainForCommit("deadbeef", SUFFIX);
|
|
22
|
-
expect(d.endsWith(SUFFIX)).toBe(true);
|
|
23
|
-
const other = domainForCommit("deadbeef", ".other.zone");
|
|
24
|
-
expect(other.endsWith(".other.zone")).toBe(true);
|
|
25
|
-
expect(d).not.toBe(other);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it("differs per commit sha", () => {
|
|
29
|
-
expect(domainForCommit("aaa", SUFFIX)).not.toBe(domainForCommit("bbb", SUFFIX));
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe("deriveLatestStatus", () => {
|
|
34
|
-
it("uses the matching deployment record state when present", () => {
|
|
35
|
-
const s = deriveLatestStatus({
|
|
36
|
-
latestCommitSha: "c1",
|
|
37
|
-
deployments: [{ commitSha: "c1", deploymentId: "d1", state: "live" }],
|
|
38
|
-
isAgentRunning: false,
|
|
39
|
-
});
|
|
40
|
-
expect(s.state).toBe("live");
|
|
41
|
-
expect(s.commitSha).toBe("c1");
|
|
42
|
-
expect(s.deploymentId).toBe("d1");
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("returns deploying when no record matches and agent is running", () => {
|
|
46
|
-
const s = deriveLatestStatus({
|
|
47
|
-
latestCommitSha: "c2",
|
|
48
|
-
deployments: [{ commitSha: "other", deploymentId: "d", state: "live" }],
|
|
49
|
-
isAgentRunning: true,
|
|
50
|
-
});
|
|
51
|
-
expect(s.state).toBe("deploying");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("returns idle when no record matches and agent not running", () => {
|
|
55
|
-
const s = deriveLatestStatus({
|
|
56
|
-
latestCommitSha: "c3",
|
|
57
|
-
deployments: [],
|
|
58
|
-
isAgentRunning: false,
|
|
59
|
-
});
|
|
60
|
-
expect(s.state).toBe("idle");
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
it("is live only when an explicit matching record is live", () => {
|
|
64
|
-
const s = deriveLatestStatus({
|
|
65
|
-
latestCommitSha: "c4",
|
|
66
|
-
deployments: [{ commitSha: "c4", deploymentId: "d4", state: "failed" }],
|
|
67
|
-
isAgentRunning: true,
|
|
68
|
-
});
|
|
69
|
-
expect(s.state).toBe("failed");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("never throws on empty input", () => {
|
|
73
|
-
expect(() =>
|
|
74
|
-
deriveLatestStatus({ latestCommitSha: "", deployments: [], isAgentRunning: false }),
|
|
75
|
-
).not.toThrow();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe("deriveTimelineFromCommits", () => {
|
|
80
|
-
const commits: DeploymentCommitRef[] = [
|
|
81
|
-
{ sha: "head", message: "head msg", date: "2026-05-17" },
|
|
82
|
-
{ sha: "mid", message: "mid msg", date: "2026-05-16" },
|
|
83
|
-
{ sha: "old", message: "old msg", date: "2026-05-15" },
|
|
84
|
-
];
|
|
85
|
-
|
|
86
|
-
it("produces one entry per commit preserving input order", () => {
|
|
87
|
-
const t = deriveTimelineFromCommits(commits, [], {
|
|
88
|
-
domainSuffix: SUFFIX,
|
|
89
|
-
isAgentRunning: false,
|
|
90
|
-
latestCommitSha: "head",
|
|
91
|
-
});
|
|
92
|
-
expect(t.map((e) => e.commitSha)).toEqual(["head", "mid", "old"]);
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it("head gets deploying when agent running and no terminal record", () => {
|
|
96
|
-
const t = deriveTimelineFromCommits(commits, [], {
|
|
97
|
-
domainSuffix: SUFFIX,
|
|
98
|
-
isAgentRunning: true,
|
|
99
|
-
latestCommitSha: "head",
|
|
100
|
-
});
|
|
101
|
-
expect(t[0]?.state).toBe("deploying");
|
|
102
|
-
expect(t[1]?.state).toBe("idle");
|
|
103
|
-
expect(t[2]?.state).toBe("idle");
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
it("matching deployment record wins over agent-running default", () => {
|
|
107
|
-
const t = deriveTimelineFromCommits(
|
|
108
|
-
commits,
|
|
109
|
-
[{ commitSha: "head", deploymentId: "d", state: "failed" }],
|
|
110
|
-
{
|
|
111
|
-
domainSuffix: SUFFIX,
|
|
112
|
-
isAgentRunning: true,
|
|
113
|
-
latestCommitSha: "head",
|
|
114
|
-
},
|
|
115
|
-
);
|
|
116
|
-
expect(t[0]?.state).toBe("failed");
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("url only present for live/deploying entries", () => {
|
|
120
|
-
const t = deriveTimelineFromCommits(
|
|
121
|
-
commits,
|
|
122
|
-
[{ commitSha: "mid", deploymentId: "d", state: "live" }],
|
|
123
|
-
{
|
|
124
|
-
domainSuffix: SUFFIX,
|
|
125
|
-
isAgentRunning: true,
|
|
126
|
-
latestCommitSha: "head",
|
|
127
|
-
},
|
|
128
|
-
);
|
|
129
|
-
expect(t[0]?.url).toBe(domainForCommit("head", SUFFIX)); // deploying
|
|
130
|
-
expect(t[1]?.url).toBe(domainForCommit("mid", SUFFIX)); // live
|
|
131
|
-
expect(t[2]?.url).toBeUndefined(); // idle
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
it("does not mutate inputs", () => {
|
|
135
|
-
const cs = structuredClone(commits);
|
|
136
|
-
const ds: DeploymentRecord[] = [{ commitSha: "head", deploymentId: "d", state: "live" }];
|
|
137
|
-
const dsClone = structuredClone(ds);
|
|
138
|
-
deriveTimelineFromCommits(cs, ds, {
|
|
139
|
-
domainSuffix: SUFFIX,
|
|
140
|
-
isAgentRunning: true,
|
|
141
|
-
latestCommitSha: "head",
|
|
142
|
-
});
|
|
143
|
-
expect(cs).toEqual(commits);
|
|
144
|
-
expect(ds).toEqual(dsClone);
|
|
145
|
-
});
|
|
146
|
-
});
|
|
147
|
-
|
|
148
|
-
describe("advanceState", () => {
|
|
149
|
-
it("allows legal transitions", () => {
|
|
150
|
-
expect(advanceState("idle", "start")).toBe("deploying");
|
|
151
|
-
expect(advanceState("deploying", "succeed")).toBe("live");
|
|
152
|
-
expect(advanceState("deploying", "fail")).toBe("failed");
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("reset always returns idle", () => {
|
|
156
|
-
expect(advanceState("live", "reset")).toBe("idle");
|
|
157
|
-
expect(advanceState("failed", "reset")).toBe("idle");
|
|
158
|
-
expect(advanceState("idle", "reset")).toBe("idle");
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
it("throws on illegal transitions", () => {
|
|
162
|
-
expect(() => advanceState("idle", "succeed")).toThrow();
|
|
163
|
-
expect(() => advanceState("live", "start")).toThrow();
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
describe("tenant-scoped entries", () => {
|
|
168
|
-
it("fails closed on empty tenantId", () => {
|
|
169
|
-
expect(() =>
|
|
170
|
-
getDeploymentStatus("", {
|
|
171
|
-
latestCommitSha: "c1",
|
|
172
|
-
deployments: [],
|
|
173
|
-
isAgentRunning: false,
|
|
174
|
-
}),
|
|
175
|
-
).toThrow();
|
|
176
|
-
expect(() =>
|
|
177
|
-
getDeploymentTimeline("", [], [], {
|
|
178
|
-
domainSuffix: SUFFIX,
|
|
179
|
-
isAgentRunning: false,
|
|
180
|
-
latestCommitSha: "c1",
|
|
181
|
-
}),
|
|
182
|
-
).toThrow();
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
it("returns status for a valid tenant", () => {
|
|
186
|
-
const s = getDeploymentStatus("tenant_1", {
|
|
187
|
-
latestCommitSha: "c1",
|
|
188
|
-
deployments: [{ commitSha: "c1", deploymentId: "d1", state: "live" }],
|
|
189
|
-
isAgentRunning: false,
|
|
190
|
-
});
|
|
191
|
-
expect(s.state).toBe("live");
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
it("returns timeline for a valid tenant", () => {
|
|
195
|
-
const t = getDeploymentTimeline(
|
|
196
|
-
"tenant_1",
|
|
197
|
-
[{ sha: "head", message: "m", date: "2026-05-17" }],
|
|
198
|
-
[],
|
|
199
|
-
{ domainSuffix: SUFFIX, isAgentRunning: true, latestCommitSha: "head" },
|
|
200
|
-
);
|
|
201
|
-
expect(t).toHaveLength(1);
|
|
202
|
-
expect(t[0]?.state).toBe("deploying");
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
it("rejects malformed input via zod", () => {
|
|
206
|
-
expect(() =>
|
|
207
|
-
getDeploymentStatus("tenant_1", {
|
|
208
|
-
// @ts-expect-error invalid state
|
|
209
|
-
deployments: [{ commitSha: "c1", deploymentId: "d1", state: "bogus" }],
|
|
210
|
-
latestCommitSha: "c1",
|
|
211
|
-
isAgentRunning: false,
|
|
212
|
-
}),
|
|
213
|
-
).toThrow();
|
|
214
|
-
});
|
|
215
|
-
});
|
package/src/deployment-status.ts
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Deployment lifecycle status model.
|
|
5
|
-
*
|
|
6
|
-
* Commit-keyed deploy state plus a newest-first timeline derivation.
|
|
7
|
-
* Pure, deterministic, immutable. Multi-tenant fail-closed at the
|
|
8
|
-
* public boundary. The preview domain suffix is always injected —
|
|
9
|
-
* there is no hardcoded host anywhere in this module.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export type DeployState = "idle" | "deploying" | "live" | "failed";
|
|
13
|
-
|
|
14
|
-
export interface DeploymentUiStatus {
|
|
15
|
-
state: DeployState;
|
|
16
|
-
commitSha?: string | undefined;
|
|
17
|
-
url?: string | undefined;
|
|
18
|
-
deploymentId?: string | undefined;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface DeploymentTimelineEntry {
|
|
22
|
-
commitSha: string;
|
|
23
|
-
commitMessage: string;
|
|
24
|
-
commitDate: string;
|
|
25
|
-
state: DeployState;
|
|
26
|
-
url?: string | undefined;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** Structural commit reference — declared locally, no sibling imports. */
|
|
30
|
-
export interface DeploymentCommitRef {
|
|
31
|
-
sha: string;
|
|
32
|
-
message: string;
|
|
33
|
-
date: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface DeploymentRecord {
|
|
37
|
-
commitSha: string;
|
|
38
|
-
deploymentId: string | null;
|
|
39
|
-
state: DeployState;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export class DeploymentStateError extends Error {
|
|
43
|
-
constructor(message: string) {
|
|
44
|
-
super(message);
|
|
45
|
-
this.name = "DeploymentStateError";
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const deployStateSchema = z.enum(["idle", "deploying", "live", "failed"]);
|
|
50
|
-
|
|
51
|
-
const deploymentRecordSchema = z.object({
|
|
52
|
-
commitSha: z.string(),
|
|
53
|
-
deploymentId: z.string().nullable(),
|
|
54
|
-
state: deployStateSchema,
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
const commitRefSchema = z.object({
|
|
58
|
-
sha: z.string(),
|
|
59
|
-
message: z.string(),
|
|
60
|
-
date: z.string(),
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
const latestStatusInputSchema = z.object({
|
|
64
|
-
latestCommitSha: z.string(),
|
|
65
|
-
deployments: z.array(deploymentRecordSchema),
|
|
66
|
-
isAgentRunning: z.boolean(),
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const timelineOptsSchema = z.object({
|
|
70
|
-
domainSuffix: z.string().min(1),
|
|
71
|
-
isAgentRunning: z.boolean(),
|
|
72
|
-
latestCommitSha: z.string(),
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
export type LatestStatusInput = z.infer<typeof latestStatusInputSchema>;
|
|
76
|
-
export type TimelineOpts = z.infer<typeof timelineOptsSchema>;
|
|
77
|
-
|
|
78
|
-
/** Stable, non-cryptographic hash → short deterministic slug. */
|
|
79
|
-
function slugForSha(commitSha: string): string {
|
|
80
|
-
let hash = 2166136261;
|
|
81
|
-
for (let i = 0; i < commitSha.length; i += 1) {
|
|
82
|
-
hash ^= commitSha.charCodeAt(i);
|
|
83
|
-
hash = Math.imul(hash, 16777619);
|
|
84
|
-
}
|
|
85
|
-
return (hash >>> 0).toString(36);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Deterministic per-commit preview domain built from an injected suffix.
|
|
90
|
-
* No hardcoded host — the suffix fully determines the zone.
|
|
91
|
-
*/
|
|
92
|
-
export function domainForCommit(commitSha: string, domainSuffix: string): string {
|
|
93
|
-
return `${slugForSha(commitSha)}${domainSuffix}`;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function findRecord(
|
|
97
|
-
deployments: readonly DeploymentRecord[],
|
|
98
|
-
commitSha: string,
|
|
99
|
-
): DeploymentRecord | undefined {
|
|
100
|
-
return deployments.find((d) => d.commitSha === commitSha);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Faithful rule: matching record state wins; else deploying when the
|
|
105
|
-
* agent is running; else idle. `live` only when a matching record is
|
|
106
|
-
* explicitly live. Never throws.
|
|
107
|
-
*/
|
|
108
|
-
export function deriveLatestStatus(input: {
|
|
109
|
-
latestCommitSha: string;
|
|
110
|
-
deployments: readonly DeploymentRecord[];
|
|
111
|
-
isAgentRunning: boolean;
|
|
112
|
-
}): DeploymentUiStatus {
|
|
113
|
-
const { latestCommitSha, deployments, isAgentRunning } = input;
|
|
114
|
-
const record = findRecord(deployments, latestCommitSha);
|
|
115
|
-
|
|
116
|
-
if (record) {
|
|
117
|
-
return {
|
|
118
|
-
state: record.state,
|
|
119
|
-
commitSha: latestCommitSha,
|
|
120
|
-
deploymentId: record.deploymentId ?? undefined,
|
|
121
|
-
};
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (isAgentRunning) {
|
|
125
|
-
return { state: "deploying", commitSha: latestCommitSha };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
return { state: "idle", commitSha: latestCommitSha };
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* One entry per commit, newest-first order preserved from input.
|
|
133
|
-
* Matching deployment record wins; the head commit gets `deploying`
|
|
134
|
-
* when the agent is running and has no terminal record; older
|
|
135
|
-
* unmatched commits resolve to `idle`. Immutable, deterministic.
|
|
136
|
-
*/
|
|
137
|
-
export function deriveTimelineFromCommits(
|
|
138
|
-
commits: readonly DeploymentCommitRef[],
|
|
139
|
-
deployments: readonly DeploymentRecord[],
|
|
140
|
-
opts: TimelineOpts,
|
|
141
|
-
): DeploymentTimelineEntry[] {
|
|
142
|
-
const { domainSuffix, isAgentRunning, latestCommitSha } = opts;
|
|
143
|
-
|
|
144
|
-
return commits.map((commit) => {
|
|
145
|
-
const record = findRecord(deployments, commit.sha);
|
|
146
|
-
let state: DeployState;
|
|
147
|
-
|
|
148
|
-
if (record) {
|
|
149
|
-
state = record.state;
|
|
150
|
-
} else if (commit.sha === latestCommitSha && isAgentRunning) {
|
|
151
|
-
state = "deploying";
|
|
152
|
-
} else {
|
|
153
|
-
state = "idle";
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const entry: DeploymentTimelineEntry = {
|
|
157
|
-
commitSha: commit.sha,
|
|
158
|
-
commitMessage: commit.message,
|
|
159
|
-
commitDate: commit.date,
|
|
160
|
-
state,
|
|
161
|
-
};
|
|
162
|
-
|
|
163
|
-
if (state === "live" || state === "deploying") {
|
|
164
|
-
return { ...entry, url: domainForCommit(commit.sha, domainSuffix) };
|
|
165
|
-
}
|
|
166
|
-
return entry;
|
|
167
|
-
});
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
type TransitionEvent = "start" | "succeed" | "fail";
|
|
171
|
-
|
|
172
|
-
const LEGAL_TRANSITIONS: Record<TransitionEvent, Partial<Record<DeployState, DeployState>>> = {
|
|
173
|
-
start: { idle: "deploying", failed: "deploying" },
|
|
174
|
-
succeed: { deploying: "live" },
|
|
175
|
-
fail: { deploying: "failed" },
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
/**
|
|
179
|
-
* Explicit deploy state machine. Illegal transitions throw a typed
|
|
180
|
-
* error. `reset` always returns to idle regardless of current state.
|
|
181
|
-
*/
|
|
182
|
-
export function advanceState(
|
|
183
|
-
current: DeployState,
|
|
184
|
-
event: "start" | "succeed" | "fail" | "reset",
|
|
185
|
-
): DeployState {
|
|
186
|
-
if (event === "reset") {
|
|
187
|
-
return "idle";
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
const next = LEGAL_TRANSITIONS[event][current];
|
|
191
|
-
if (next === undefined) {
|
|
192
|
-
throw new DeploymentStateError(
|
|
193
|
-
`Illegal deploy transition: cannot '${event}' from '${current}'`,
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
return next;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
function assertTenant(tenantId: string): void {
|
|
200
|
-
if (typeof tenantId !== "string" || tenantId.trim() === "") {
|
|
201
|
-
throw new DeploymentStateError("tenantId is required (fail-closed)");
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/** Public tenant-scoped status entry — fail-closed, zod-validated. */
|
|
206
|
-
export function getDeploymentStatus(
|
|
207
|
-
tenantId: string,
|
|
208
|
-
input: LatestStatusInput,
|
|
209
|
-
): DeploymentUiStatus {
|
|
210
|
-
assertTenant(tenantId);
|
|
211
|
-
const parsed = latestStatusInputSchema.parse(input);
|
|
212
|
-
return deriveLatestStatus(parsed);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Public tenant-scoped timeline entry — fail-closed, zod-validated. */
|
|
216
|
-
export function getDeploymentTimeline(
|
|
217
|
-
tenantId: string,
|
|
218
|
-
commits: readonly DeploymentCommitRef[],
|
|
219
|
-
deployments: readonly DeploymentRecord[],
|
|
220
|
-
opts: TimelineOpts,
|
|
221
|
-
): DeploymentTimelineEntry[] {
|
|
222
|
-
assertTenant(tenantId);
|
|
223
|
-
const parsedCommits = z.array(commitRefSchema).parse(commits);
|
|
224
|
-
const parsedDeployments = z.array(deploymentRecordSchema).parse(deployments);
|
|
225
|
-
const parsedOpts = timelineOptsSchema.parse(opts);
|
|
226
|
-
return deriveTimelineFromCommits(parsedCommits, parsedDeployments, parsedOpts);
|
|
227
|
-
}
|