@warpgogol/forge 2.8.0 → 2.8.2
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/AGENTS.md +1 -1
- package/README.md +23 -13
- package/README.uk.md +19 -12
- package/dist/os/session/handlers/validate.d.ts.map +1 -1
- package/dist/os/session/handlers/validate.js +23 -1
- package/dist/os/session/handlers/validate.js.map +1 -1
- package/dist/os/session/types.d.ts +31 -1
- package/dist/os/session/types.d.ts.map +1 -1
- package/dist/os/session/types.js +7 -0
- package/dist/os/session/types.js.map +1 -1
- package/os/adr/adr.module.ts +158 -0
- package/os/adr/frontmatter-io.ts +80 -0
- package/os/adr/handlers/archive.ts +244 -0
- package/os/adr/handlers/implement-stamp.ts +331 -0
- package/os/adr/handlers/list-create.ts +207 -0
- package/os/adr/handlers/validate.test.ts +232 -0
- package/os/adr/handlers/validate.ts +429 -0
- package/os/adr/index.ts +33 -0
- package/os/adr/types.ts +141 -0
- package/os/audit/audit.module.ts +48 -0
- package/os/audit/frontmatter-io.ts +84 -0
- package/os/audit/handlers/archive.ts +239 -0
- package/os/audit/index.ts +23 -0
- package/os/audit/types.ts +29 -0
- package/os/compass/compass.module.ts +186 -0
- package/os/compass/handlers/compass-audit-handler.ts +382 -0
- package/os/compass/handlers/compass-change-summary-handler.ts +272 -0
- package/os/compass/handlers/compass-inventory-handler.ts +294 -0
- package/os/compass/handlers/compass-inventory.ts +521 -0
- package/os/compass/handlers/git-revision.ts +137 -0
- package/os/compass/handlers/resolve-scan-root-workpiece.test.ts +65 -0
- package/os/compass/handlers/resolve-scan-root.ts +79 -0
- package/os/compass/index.ts +33 -0
- package/os/core/core.module.ts +948 -0
- package/os/core/handlers/assets-check.ts +122 -0
- package/os/core/handlers/assets-helpers.ts +174 -0
- package/os/core/handlers/assets-list.ts +99 -0
- package/os/core/handlers/build.ts +155 -0
- package/os/core/handlers/determinism-check.ts +354 -0
- package/os/core/handlers/dev.ts +127 -0
- package/os/core/handlers/invariant-engine.test.ts +681 -0
- package/os/core/handlers/knowledge-compact.ts +248 -0
- package/os/core/handlers/lifecycle-handlers.test.ts +524 -0
- package/os/core/handlers/note-frontmatter-validate.test.ts +93 -0
- package/os/core/handlers/note-link-validate.test.ts +117 -0
- package/os/core/handlers/note-orphan-detect.test.ts +94 -0
- package/os/core/handlers/package-health.test.ts +226 -0
- package/os/core/handlers/package-health.ts +231 -0
- package/os/core/handlers/pinned-check.ts +200 -0
- package/os/core/handlers/pinned-init.ts +333 -0
- package/os/core/handlers/pinned-types.ts +50 -0
- package/os/core/handlers/pinned-validate.ts +301 -0
- package/os/core/handlers/profile-resolve.ts +92 -0
- package/os/core/handlers/release-prepare.ts +294 -0
- package/os/core/handlers/release-publish.ts +211 -0
- package/os/core/handlers/validate.ts +262 -0
- package/os/core/handlers/workspace-deps.ts +55 -0
- package/os/core/index.ts +13 -0
- package/os/exploration/exploration.module.ts +90 -0
- package/os/exploration/frontmatter-io.ts +77 -0
- package/os/exploration/handlers/archive.ts +159 -0
- package/os/exploration/handlers/list.ts +77 -0
- package/os/exploration/handlers/show.ts +107 -0
- package/os/exploration/index.ts +25 -0
- package/os/exploration/types.ts +65 -0
- package/os/mission/handlers/archive.test.ts +391 -0
- package/os/mission/handlers/archive.ts +453 -0
- package/os/mission/index.ts +1 -0
- package/os/mission/mission.module.ts +48 -0
- package/os/mission/types.ts +41 -0
- package/os/naming/index.ts +14 -0
- package/os/naming/naming-convention.test.ts +163 -0
- package/os/naming/naming-convention.ts +378 -0
- package/os/naming/naming.module.ts +36 -0
- package/os/notes/index.ts +10 -0
- package/os/notes/notes.module.ts +109 -0
- package/os/plan/frontmatter-io.ts +82 -0
- package/os/plan/handlers/archive.ts +238 -0
- package/os/plan/index.ts +23 -0
- package/os/plan/plan.module.ts +48 -0
- package/os/plan/types.ts +29 -0
- package/os/program/discovery.ts +294 -0
- package/os/program/handlers/complete.ts +419 -0
- package/os/program/handlers/lease.ts +433 -0
- package/os/program/handlers/seal.ts +332 -0
- package/os/program/handlers/validate.ts +213 -0
- package/os/program/lease.ts +156 -0
- package/os/program/program.module.ts +212 -0
- package/os/program/schemas.ts +218 -0
- package/os/program/state.ts +264 -0
- package/os/rfc/acceptance.ts +344 -0
- package/os/rfc/decision-log.ts +310 -0
- package/os/rfc/dna-trace.ts +280 -0
- package/os/rfc/frontmatter-io.test.ts +132 -0
- package/os/rfc/frontmatter-io.ts +130 -0
- package/os/rfc/handlers/archive.ts +300 -0
- package/os/rfc/handlers/check.ts +238 -0
- package/os/rfc/handlers/implement-stamp.ts +515 -0
- package/os/rfc/handlers/index-graph.ts +237 -0
- package/os/rfc/handlers/lifecycle.test.ts +127 -0
- package/os/rfc/handlers/lifecycle.ts +249 -0
- package/os/rfc/handlers/list-create.ts +296 -0
- package/os/rfc/handlers/pipeline-status.ts +173 -0
- package/os/rfc/handlers/shared.ts +121 -0
- package/os/rfc/handlers/supersede-propose.ts +241 -0
- package/os/rfc/handlers/validate-rules.test.ts +891 -0
- package/os/rfc/handlers/validate-rules.ts +984 -0
- package/os/rfc/handlers/validate.ts +166 -0
- package/os/rfc/handlers.ts +19 -0
- package/os/rfc/index.ts +97 -0
- package/os/rfc/rfc.module.ts +416 -0
- package/os/rfc/types.ts +596 -0
- package/os/rfc/verification-evidence.ts +244 -0
- package/os/session/atif-parser.ts +166 -0
- package/os/session/frontmatter-io.ts +163 -0
- package/os/session/handlers/archive.ts +288 -0
- package/os/session/handlers/list.ts +174 -0
- package/os/session/handlers/save.ts +348 -0
- package/os/session/handlers/validate.ts +250 -0
- package/os/session/index.ts +50 -0
- package/os/session/session.module.ts +126 -0
- package/os/session/types.ts +203 -0
- package/os/spec/live-spec-list-show-validate.test.ts +257 -0
- package/os/spec/live-spec-list.ts +78 -0
- package/os/spec/live-spec-merge.test.ts +261 -0
- package/os/spec/live-spec-merge.ts +413 -0
- package/os/spec/live-spec-show.ts +117 -0
- package/os/spec/live-spec-types.ts +93 -0
- package/os/spec/live-spec-validate.ts +170 -0
- package/os/spec/spec-materialize.ts +390 -0
- package/os/spec/spec-schema.ts +174 -0
- package/os/spec/spec-status.ts +284 -0
- package/os/spec/spec-validate.ts +513 -0
- package/os/spec/spec.module.ts +135 -0
- package/os/werkstatt/handlers/lock.ts +168 -0
- package/os/werkstatt/handlers/schema.ts +47 -0
- package/os/werkstatt/handlers/werkstatt-lock-recover.ts +175 -0
- package/os/werkstatt/handlers/werkstatt-lock-status.ts +73 -0
- package/os/werkstatt/handlers/werkstatt-operation-validate.ts +104 -0
- package/os/werkstatt/index.ts +39 -0
- package/os/werkstatt/werkstatt.module.ts +62 -0
- package/os/workflow/handlers.ts +304 -0
- package/os/workflow/index.ts +28 -0
- package/os/workflow/types.ts +99 -0
- package/os/workflow/workflow.module.ts +54 -0
- package/package.json +3 -3
- package/profiles/godot-csharp.yaml +4 -4
- package/profiles/knowledge-typescript-turborepo.yaml +110 -0
- package/profiles/phaser-turborepo.yaml +5 -5
- package/skills/_shared/fo-session-summary.md +132 -16
- package/skills/fo/fo-add-tests/pbt-guide.md +13 -13
- package/skills/fo/fo-doc-audit/SKILL.md +6 -6
- package/skills/fo/fo-handoff/SKILL.md +5 -0
- package/skills/fo/fo-idea/SKILL.md +1 -1
- package/skills/fo/fo-review/SKILL.md +1 -1
- package/skills/fo/fo-session-retro/SKILL.md +8 -0
- package/skills/fo/fo-session-save/SKILL.md +8 -0
- package/src/cli-output.ts +59 -0
- package/src/config/__tests__/resolve-terminology.test.ts +45 -0
- package/src/config/forge-config.ts +524 -0
- package/src/forge-module.ts +39 -0
- package/src/index.ts +172 -0
- package/src/knowledge/__tests__/promote.test.ts +219 -0
- package/src/knowledge/__tests__/serialize.test.ts +192 -0
- package/src/knowledge/budgets.ts +202 -0
- package/src/knowledge/compact.ts +405 -0
- package/src/knowledge/index.ts +53 -0
- package/src/knowledge/parse.ts +227 -0
- package/src/knowledge/promote.ts +156 -0
- package/src/knowledge/schema.ts +112 -0
- package/src/knowledge/serialize.ts +72 -0
- package/src/migration-adapters/__tests__/ignored-files.test.ts +51 -0
- package/src/migration-adapters/__tests__/registry.test.ts +131 -0
- package/src/migration-adapters/__tests__/types.test.ts +86 -0
- package/src/migration-adapters/git-utils.ts +71 -0
- package/src/migration-adapters/ignored-files.ts +221 -0
- package/src/migration-adapters/index.ts +21 -0
- package/src/migration-adapters/node-typescript-pnpm/index.ts +189 -0
- package/src/migration-adapters/phaser-pnpm/index.ts +189 -0
- package/src/migration-adapters/registry.ts +61 -0
- package/src/migration-adapters/types.ts +68 -0
- package/src/onboarding/__tests__/workspace-discovery.test.ts +134 -0
- package/src/onboarding/agents-generate.ts +443 -0
- package/src/onboarding/create.ts +427 -0
- package/src/onboarding/doctor.ts +1297 -0
- package/src/onboarding/init.ts +337 -0
- package/src/onboarding/invariant-engine.ts +448 -0
- package/src/onboarding/memory-scaffold.ts +189 -0
- package/src/onboarding/nested-agents-generate.ts +93 -0
- package/src/onboarding/nested-agents-templates.ts +233 -0
- package/src/onboarding/profile-validate.ts +139 -0
- package/src/onboarding/scaffold-project.ts +299 -0
- package/src/onboarding/scaffold.ts +143 -0
- package/src/onboarding/upgrade.ts +558 -0
- package/src/onboarding/workspace-discovery.ts +167 -0
- package/src/profiles/__tests__/profile-schema.test.ts +228 -0
- package/src/profiles/__tests__/stack-profile.test.ts +190 -0
- package/src/profiles/__tests__/terminology-utils.test.ts +71 -0
- package/src/profiles/profile-schema.ts +350 -0
- package/src/profiles/stack-profile.ts +188 -0
- package/src/profiles/terminology-utils.ts +36 -0
- package/src/registry.ts +185 -0
- package/src/skill-schema.ts +37 -0
- package/src/tests/acceptance-criteria.test.ts +99 -0
- package/src/tests/adr-implement-stamp.test.ts +368 -0
- package/src/tests/agents-generate-domain.test.ts +317 -0
- package/src/tests/agents-generate.test.ts +458 -0
- package/src/tests/bindings-schema.test.ts +314 -0
- package/src/tests/budgets.test.ts +213 -0
- package/src/tests/cli-output.test.ts +196 -0
- package/src/tests/compact.test.ts +796 -0
- package/src/tests/create.test.ts +289 -0
- package/src/tests/doctor-autonomy.test.ts +98 -0
- package/src/tests/doctor-bindings.test.ts +286 -0
- package/src/tests/doctor-domain.test.ts +113 -0
- package/src/tests/exploration-handlers.test.ts +256 -0
- package/src/tests/fixtures/agents-generate-business-before.txt +288 -0
- package/src/tests/forge-config.test.ts +420 -0
- package/src/tests/fs-atomic.test.ts +96 -0
- package/src/tests/fs.test.ts +103 -0
- package/src/tests/generated-marker.test.ts +161 -0
- package/src/tests/hash.test.ts +37 -0
- package/src/tests/implement-stamp.test.ts +505 -0
- package/src/tests/init-bindings.test.ts +183 -0
- package/src/tests/knowledge-parse.test.ts +307 -0
- package/src/tests/knowledge-pbt.test.ts +164 -0
- package/src/tests/memory-scaffold.test.ts +192 -0
- package/src/tests/migration-adapters.test.ts +480 -0
- package/src/tests/package-files.test.ts +54 -0
- package/src/tests/pinned-check.test.ts +194 -0
- package/src/tests/pinned-init.test.ts +126 -0
- package/src/tests/profile-schema.test.ts +194 -0
- package/src/tests/profile-validate.test.ts +120 -0
- package/src/tests/program-lease.test.ts +245 -0
- package/src/tests/program-paths-extended.test.ts +117 -0
- package/src/tests/program-paths.test.ts +98 -0
- package/src/tests/program-property.test.ts +139 -0
- package/src/tests/program-schemas.test.ts +445 -0
- package/src/tests/program-spec-node.test.ts +250 -0
- package/src/tests/promote.test.ts +481 -0
- package/src/tests/registry.test.ts +73 -0
- package/src/tests/scaffold-project.test.ts +150 -0
- package/src/tests/session-handlers.test.ts +503 -0
- package/src/tests/session-pbt.test.ts +190 -0
- package/src/tests/skill-schema.test.ts +146 -0
- package/src/tests/skill-validate-knowledge.test.ts +26 -0
- package/src/tests/skill-validate.test.ts +254 -0
- package/src/tests/stack-profile.test.ts +221 -0
- package/src/tests/string-utils.test.ts +42 -0
- package/src/tests/upgrade.test.ts +428 -0
- package/src/tests/werkstatt-lock.test.ts +343 -0
- package/src/tests/workspace-discovery-domain.test.ts +112 -0
- package/src/tests/workspace-discovery.test.ts +135 -0
- package/src/types.ts +221 -0
- package/src/utils/__tests__/fs-idempotent.test.ts +73 -0
- package/src/utils/__tests__/fs-trash.test.ts +45 -0
- package/src/utils/fs-atomic.ts +93 -0
- package/src/utils/fs-idempotent.ts +41 -0
- package/src/utils/fs-trash-sync.ts +37 -0
- package/src/utils/fs-trash.ts +24 -0
- package/src/utils/fs.ts +74 -0
- package/src/utils/generated-marker.ts +166 -0
- package/src/utils/hash.ts +19 -0
- package/src/utils/index.ts +29 -0
- package/src/utils/string-utils.ts +19 -0
- package/src/validators/__tests__/note-orphan-detect.test.ts +94 -0
- package/src/validators/note-frontmatter-validate.ts +142 -0
- package/src/validators/note-link-validate.ts +145 -0
- package/src/validators/note-orphan-detect.ts +146 -0
- package/src/validators/port-validate.ts +97 -0
- package/src/validators/skill-validate.ts +831 -0
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Command handler for program.packet.seal — Steward finalizes a packet
|
|
4
|
+
against the predecessor's completion commit, updates packet state from draft
|
|
5
|
+
to sealed, and records the seal in the program manifest (RFC-0856).</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not commit — seal writes files only; canonical commit is separate.</item>
|
|
8
|
+
<item>Do not import from @warpgogol/* — forge must remain dependency-free.</item>
|
|
9
|
+
</non-goals>
|
|
10
|
+
</MODULE_CONTRACT>
|
|
11
|
+
<CHANGE_SUMMARY>
|
|
12
|
+
<item>RFC-0856: initial seal command handler.</item>
|
|
13
|
+
<item>RFC-0857: reciprocal specRef/materializedAs mapping verification for spec-node packets (AC-7).</item>
|
|
14
|
+
</CHANGE_SUMMARY>
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
20
|
+
import type {
|
|
21
|
+
ForgeCommandInput,
|
|
22
|
+
ForgeCommandResult,
|
|
23
|
+
ForgeRuntimeContext,
|
|
24
|
+
} from "../../../src/types.ts";
|
|
25
|
+
import { writeFileAtomic } from "../../../src/utils/fs-atomic.ts";
|
|
26
|
+
import {
|
|
27
|
+
discoverProgram,
|
|
28
|
+
findPacketEntry,
|
|
29
|
+
resolvePacketPath,
|
|
30
|
+
parsePacketFile,
|
|
31
|
+
verifyNormativeSources,
|
|
32
|
+
gitBranch,
|
|
33
|
+
gitHead,
|
|
34
|
+
gitIsClean,
|
|
35
|
+
} from "../discovery.ts";
|
|
36
|
+
import { findActiveLeases, isLeaseStale } from "../lease.ts";
|
|
37
|
+
import { validateSealTransition, type PacketViolation } from "../state.ts";
|
|
38
|
+
import type { ProgramManifest, ProgramPacket, ProgramPacketIndexEntry } from "../schemas.ts";
|
|
39
|
+
|
|
40
|
+
export interface SealResultData {
|
|
41
|
+
command: string;
|
|
42
|
+
status: "pass" | "fail";
|
|
43
|
+
program: string;
|
|
44
|
+
packetId: string;
|
|
45
|
+
filesModified: string[];
|
|
46
|
+
violations: PacketViolation[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runSeal(
|
|
50
|
+
input: ForgeCommandInput,
|
|
51
|
+
context: ForgeRuntimeContext,
|
|
52
|
+
): Promise<ForgeCommandResult<SealResultData>> {
|
|
53
|
+
const program = input.flags.program as string | undefined;
|
|
54
|
+
const packetId = input.flags.packet as string | undefined;
|
|
55
|
+
const steward = input.flags.steward as string | undefined;
|
|
56
|
+
const _idempotencyKey = input.flags["idempotency-key"] as string | undefined;
|
|
57
|
+
|
|
58
|
+
if (!program || !packetId || !steward) {
|
|
59
|
+
return {
|
|
60
|
+
data: {
|
|
61
|
+
command: "program.packet.seal",
|
|
62
|
+
status: "fail",
|
|
63
|
+
program: program ?? "<missing>",
|
|
64
|
+
packetId: packetId ?? "<missing>",
|
|
65
|
+
filesModified: [],
|
|
66
|
+
violations: [
|
|
67
|
+
{
|
|
68
|
+
rule: "PROGRAM-PACKET-01",
|
|
69
|
+
message: "--program, --packet, and --steward are required",
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
exitCode: 1,
|
|
74
|
+
summary: "program.packet.seal: missing required flags",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const { workspaceRoot } = context;
|
|
79
|
+
|
|
80
|
+
// Discover program
|
|
81
|
+
let programLocation;
|
|
82
|
+
try {
|
|
83
|
+
programLocation = discoverProgram(workspaceRoot, program);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return sealFail(program, packetId, [
|
|
86
|
+
{ rule: "PROGRAM-PACKET-01", message: String((err as Error).message) },
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!programLocation) {
|
|
91
|
+
return sealFail(program, packetId, [
|
|
92
|
+
{ rule: "PROGRAM-PACKET-01", message: `program ${program} not found` },
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const { manifest, manifestPath, programDir } = programLocation;
|
|
97
|
+
|
|
98
|
+
// Find packet entry
|
|
99
|
+
const entry = findPacketEntry(manifest, packetId);
|
|
100
|
+
if (!entry) {
|
|
101
|
+
return sealFail(program, packetId, [
|
|
102
|
+
{ rule: "PROGRAM-PACKET-01", message: `packet ${packetId} not found` },
|
|
103
|
+
]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Parse packet file
|
|
107
|
+
let packet: ProgramPacket;
|
|
108
|
+
try {
|
|
109
|
+
packet = parsePacketFile(resolvePacketPath(programDir, entry));
|
|
110
|
+
} catch (err) {
|
|
111
|
+
return sealFail(program, packetId, [
|
|
112
|
+
{ rule: "PROGRAM-PACKET-01", message: String((err as Error).message) },
|
|
113
|
+
]);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Verify normative source hashes
|
|
117
|
+
const hashViolations = verifyNormativeSources(workspaceRoot, packet);
|
|
118
|
+
if (hashViolations.length > 0) {
|
|
119
|
+
return sealFail(
|
|
120
|
+
program,
|
|
121
|
+
packetId,
|
|
122
|
+
hashViolations.map((v) => ({
|
|
123
|
+
rule: "PROGRAM-PACKET-07",
|
|
124
|
+
path: v.path,
|
|
125
|
+
message: `normative source hash mismatch: expected ${v.expected}, got ${v.actual}`,
|
|
126
|
+
})),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Git state
|
|
131
|
+
let branch = "";
|
|
132
|
+
let head = "";
|
|
133
|
+
try {
|
|
134
|
+
branch = gitBranch(workspaceRoot);
|
|
135
|
+
head = gitHead(workspaceRoot);
|
|
136
|
+
} catch {
|
|
137
|
+
return sealFail(program, packetId, [
|
|
138
|
+
{ rule: "PROGRAM-PACKET-02", message: "failed to read git state" },
|
|
139
|
+
]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Check for active leases
|
|
143
|
+
const activeLeases = findActiveLeases(workspaceRoot, program);
|
|
144
|
+
const hasActiveLease = activeLeases.some(
|
|
145
|
+
(l) => l.packetId === packetId && !isLeaseStale(l.lease),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
let isClean = false;
|
|
149
|
+
try {
|
|
150
|
+
isClean = gitIsClean(workspaceRoot);
|
|
151
|
+
} catch {
|
|
152
|
+
// ignore
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Validate transition
|
|
156
|
+
const violations = validateSealTransition(manifest, entry, packet, {
|
|
157
|
+
branch,
|
|
158
|
+
head,
|
|
159
|
+
isClean,
|
|
160
|
+
hasActiveLease,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (violations.length > 0) {
|
|
164
|
+
return sealFail(program, packetId, violations);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// RFC-0857 AC-7: For spec-node packets, verify reciprocal specRef/materializedAs mapping
|
|
168
|
+
if (entry.decisionKind === "spec-node" && entry.resolvedRfc) {
|
|
169
|
+
const specViolation = verifySpecNodeMapping(workspaceRoot, entry);
|
|
170
|
+
if (specViolation) {
|
|
171
|
+
return sealFail(program, packetId, [specViolation]);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- Perform seal ---
|
|
176
|
+
const filesModified: string[] = [];
|
|
177
|
+
|
|
178
|
+
// 1. Update packet file: state → sealed, baseCommit → head (for non-bootstrap)
|
|
179
|
+
const packetPath = resolvePacketPath(programDir, entry);
|
|
180
|
+
const packetRaw = fs.readFileSync(packetPath, "utf8");
|
|
181
|
+
const packetFmMatch = packetRaw.match(/^---\n([\s\S]*?)\n---/);
|
|
182
|
+
if (!packetFmMatch) {
|
|
183
|
+
return sealFail(program, packetId, [
|
|
184
|
+
{ rule: "PROGRAM-PACKET-01", message: "missing frontmatter in packet file" },
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
const packetFm = parseYaml(packetFmMatch[1]) as Record<string, unknown>;
|
|
188
|
+
packetFm.state = "sealed";
|
|
189
|
+
if (entry.dependsOnPacket === null) {
|
|
190
|
+
// Bootstrap packet: baseCommit stays null (set by completion)
|
|
191
|
+
} else {
|
|
192
|
+
packetFm.baseCommit = head;
|
|
193
|
+
}
|
|
194
|
+
const newPacketFm = stringifyYaml(packetFm);
|
|
195
|
+
const newPacketRaw = `---\n${newPacketFm}---\n${packetRaw.slice(packetFmMatch[0].length)}`;
|
|
196
|
+
writeFileAtomic(packetPath, newPacketRaw);
|
|
197
|
+
filesModified.push(path.relative(workspaceRoot, packetPath));
|
|
198
|
+
|
|
199
|
+
// 2. Update program manifest: packet state → sealed
|
|
200
|
+
const manifestRaw = fs.readFileSync(manifestPath, "utf8");
|
|
201
|
+
const manifestParsed = parseYaml(manifestRaw) as ProgramManifest;
|
|
202
|
+
const packetIdx = manifestParsed.packets.findIndex((p) => p.packetId === packetId);
|
|
203
|
+
if (packetIdx === -1) {
|
|
204
|
+
return sealFail(program, packetId, [
|
|
205
|
+
{ rule: "PROGRAM-PACKET-01", message: "packet not found in manifest" },
|
|
206
|
+
]);
|
|
207
|
+
}
|
|
208
|
+
manifestParsed.packets[packetIdx].state = "sealed";
|
|
209
|
+
if (entry.dependsOnPacket !== null) {
|
|
210
|
+
manifestParsed.packets[packetIdx].baseCommit = head;
|
|
211
|
+
}
|
|
212
|
+
const newManifestRaw = stringifyYaml(manifestParsed);
|
|
213
|
+
writeFileAtomic(manifestPath, newManifestRaw);
|
|
214
|
+
filesModified.push(path.relative(workspaceRoot, manifestPath));
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
data: {
|
|
218
|
+
command: "program.packet.seal",
|
|
219
|
+
status: "pass",
|
|
220
|
+
program,
|
|
221
|
+
packetId,
|
|
222
|
+
filesModified,
|
|
223
|
+
violations: [],
|
|
224
|
+
},
|
|
225
|
+
exitCode: 0,
|
|
226
|
+
summary: `program.packet.seal: ${packetId} sealed on ${branch}@${head.slice(0, 8)}`,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function sealFail(
|
|
231
|
+
program: string,
|
|
232
|
+
packetId: string,
|
|
233
|
+
violations: PacketViolation[],
|
|
234
|
+
): ForgeCommandResult<SealResultData> {
|
|
235
|
+
return {
|
|
236
|
+
data: {
|
|
237
|
+
command: "program.packet.seal",
|
|
238
|
+
status: "fail",
|
|
239
|
+
program,
|
|
240
|
+
packetId,
|
|
241
|
+
filesModified: [],
|
|
242
|
+
violations,
|
|
243
|
+
},
|
|
244
|
+
exitCode: 1,
|
|
245
|
+
summary: `program.packet.seal: ${packetId} failed with ${violations.length} violation(s)`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// RFC-0857 AC-7: Reciprocal specRef/materializedAs mapping verification
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
function verifySpecNodeMapping(
|
|
254
|
+
workspaceRoot: string,
|
|
255
|
+
entry: ProgramPacketIndexEntry,
|
|
256
|
+
): PacketViolation | null {
|
|
257
|
+
const governingDecision = entry.governingDecision;
|
|
258
|
+
const slashIdx = governingDecision.indexOf("/");
|
|
259
|
+
if (slashIdx === -1) {
|
|
260
|
+
return {
|
|
261
|
+
rule: "PROGRAM-PACKET-12",
|
|
262
|
+
message: `spec-node governingDecision "${governingDecision}" is not in <spec-id>/<node-id> format`,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const specId = governingDecision.slice(0, slashIdx);
|
|
266
|
+
const nodeId = governingDecision.slice(slashIdx + 1);
|
|
267
|
+
|
|
268
|
+
// Check forge-spec.yaml for materializedAs matching resolvedRfc
|
|
269
|
+
const specPath = path.join(workspaceRoot, "docs", "specs", specId, "forge-spec.yaml");
|
|
270
|
+
if (!fs.existsSync(specPath)) {
|
|
271
|
+
return {
|
|
272
|
+
rule: "PROGRAM-PACKET-12",
|
|
273
|
+
message: `spec file not found: docs/specs/${specId}/forge-spec.yaml`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const specRaw = fs.readFileSync(specPath, "utf8");
|
|
278
|
+
const specParsed = parseYaml(specRaw) as {
|
|
279
|
+
rfcs?: Array<{ id: string; materializedAs?: string }>;
|
|
280
|
+
};
|
|
281
|
+
const specNode = specParsed.rfcs?.find((r) => r.id === nodeId);
|
|
282
|
+
if (!specNode) {
|
|
283
|
+
return {
|
|
284
|
+
rule: "PROGRAM-PACKET-12",
|
|
285
|
+
message: `spec node ${nodeId} not found in docs/specs/${specId}/forge-spec.yaml`,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (specNode.materializedAs !== entry.resolvedRfc) {
|
|
290
|
+
return {
|
|
291
|
+
rule: "PROGRAM-PACKET-12",
|
|
292
|
+
message: `materializedAs (${specNode.materializedAs ?? "null"}) does not match resolvedRfc (${entry.resolvedRfc}) for ${nodeId}`,
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Check RFC file for reciprocal specRef
|
|
297
|
+
const rfcPath = path.join(workspaceRoot, "docs", "rfcs", `${entry.resolvedRfc.toLowerCase()}.md`);
|
|
298
|
+
if (!fs.existsSync(rfcPath)) {
|
|
299
|
+
return {
|
|
300
|
+
rule: "PROGRAM-PACKET-12",
|
|
301
|
+
message: `RFC file not found: docs/rfcs/${entry.resolvedRfc.toLowerCase()}.md`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const rfcRaw = fs.readFileSync(rfcPath, "utf8");
|
|
306
|
+
const rfcFmMatch = rfcRaw.match(/^---\n([\s\S]*?)\n---/);
|
|
307
|
+
if (!rfcFmMatch) {
|
|
308
|
+
return {
|
|
309
|
+
rule: "PROGRAM-PACKET-12",
|
|
310
|
+
message: `missing frontmatter in ${entry.resolvedRfc}`,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const rfcFm = parseYaml(rfcFmMatch[1]) as { specRef?: string; status?: string };
|
|
315
|
+
const expectedSpecRef = `${specId}/${nodeId}`;
|
|
316
|
+
if (rfcFm.specRef !== expectedSpecRef) {
|
|
317
|
+
return {
|
|
318
|
+
rule: "PROGRAM-PACKET-12",
|
|
319
|
+
message: `specRef (${rfcFm.specRef ?? "null"}) does not match expected ${expectedSpecRef} in ${entry.resolvedRfc}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// RFC must be accepted or implemented
|
|
324
|
+
if (rfcFm.status !== "accepted" && rfcFm.status !== "implemented") {
|
|
325
|
+
return {
|
|
326
|
+
rule: "PROGRAM-PACKET-12",
|
|
327
|
+
message: `${entry.resolvedRfc} status is "${rfcFm.status}", expected "accepted" or "implemented"`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Command handler for program.packet.validate — read-only validation
|
|
4
|
+
of a program packet against schema, source hashes, branch/head, and state
|
|
5
|
+
machine rules (RFC-0856).</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not mutate files — validate is read-only.</item>
|
|
8
|
+
<item>Do not import from @warpgogol/* — forge must remain dependency-free.</item>
|
|
9
|
+
</non-goals>
|
|
10
|
+
</MODULE_CONTRACT>
|
|
11
|
+
<CHANGE_SUMMARY>
|
|
12
|
+
<item>RFC-0856: initial validate command handler.</item>
|
|
13
|
+
</CHANGE_SUMMARY>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
ForgeCommandInput,
|
|
18
|
+
ForgeCommandResult,
|
|
19
|
+
ForgeRuntimeContext,
|
|
20
|
+
} from "../../../src/types.ts";
|
|
21
|
+
import {
|
|
22
|
+
discoverProgram,
|
|
23
|
+
findPacketEntry,
|
|
24
|
+
resolvePacketPath,
|
|
25
|
+
parsePacketFile,
|
|
26
|
+
verifyNormativeSources,
|
|
27
|
+
gitBranch,
|
|
28
|
+
gitHead,
|
|
29
|
+
gitIsClean,
|
|
30
|
+
} from "../discovery.ts";
|
|
31
|
+
import { findActiveLeases, isLeaseStale } from "../lease.ts";
|
|
32
|
+
import { validateSealTransition, type PacketViolation } from "../state.ts";
|
|
33
|
+
|
|
34
|
+
export interface ValidateResultData {
|
|
35
|
+
command: string;
|
|
36
|
+
status: "pass" | "fail";
|
|
37
|
+
program: string;
|
|
38
|
+
packetId: string;
|
|
39
|
+
phase: string;
|
|
40
|
+
branch: string;
|
|
41
|
+
head: string;
|
|
42
|
+
violations: PacketViolation[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runValidate(
|
|
46
|
+
input: ForgeCommandInput,
|
|
47
|
+
context: ForgeRuntimeContext,
|
|
48
|
+
): Promise<ForgeCommandResult<ValidateResultData>> {
|
|
49
|
+
const program = input.flags.program as string | undefined;
|
|
50
|
+
const packetId = input.flags.packet as string | undefined;
|
|
51
|
+
const phase = (input.flags.phase as string | undefined) ?? "draft";
|
|
52
|
+
|
|
53
|
+
if (!program || !packetId) {
|
|
54
|
+
return {
|
|
55
|
+
data: {
|
|
56
|
+
command: "program.packet.validate",
|
|
57
|
+
status: "fail",
|
|
58
|
+
program: program ?? "<missing>",
|
|
59
|
+
packetId: packetId ?? "<missing>",
|
|
60
|
+
phase,
|
|
61
|
+
branch: "",
|
|
62
|
+
head: "",
|
|
63
|
+
violations: [
|
|
64
|
+
{
|
|
65
|
+
rule: "PROGRAM-PACKET-01",
|
|
66
|
+
message: "both --program and --packet are required",
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
exitCode: 1,
|
|
71
|
+
summary: "program.packet.validate: missing required flags",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { workspaceRoot } = context;
|
|
76
|
+
|
|
77
|
+
// Discover program
|
|
78
|
+
let programLocation;
|
|
79
|
+
try {
|
|
80
|
+
programLocation = discoverProgram(workspaceRoot, program);
|
|
81
|
+
} catch (err) {
|
|
82
|
+
return failResult(program, packetId, phase, [
|
|
83
|
+
{
|
|
84
|
+
rule: "PROGRAM-PACKET-01",
|
|
85
|
+
message: String((err as Error).message),
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!programLocation) {
|
|
91
|
+
return failResult(program, packetId, phase, [
|
|
92
|
+
{
|
|
93
|
+
rule: "PROGRAM-PACKET-01",
|
|
94
|
+
message: `program ${program} not found in docs/plans/`,
|
|
95
|
+
},
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const { manifest, programDir } = programLocation;
|
|
100
|
+
|
|
101
|
+
// Find packet entry
|
|
102
|
+
const entry = findPacketEntry(manifest, packetId);
|
|
103
|
+
if (!entry) {
|
|
104
|
+
return failResult(program, packetId, phase, [
|
|
105
|
+
{
|
|
106
|
+
rule: "PROGRAM-PACKET-01",
|
|
107
|
+
message: `packet ${packetId} not found in program manifest`,
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Parse packet file
|
|
113
|
+
let packet;
|
|
114
|
+
try {
|
|
115
|
+
packet = parsePacketFile(resolvePacketPath(programDir, entry));
|
|
116
|
+
} catch (err) {
|
|
117
|
+
return failResult(program, packetId, phase, [
|
|
118
|
+
{
|
|
119
|
+
rule: "PROGRAM-PACKET-01",
|
|
120
|
+
message: String((err as Error).message),
|
|
121
|
+
},
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const violations: PacketViolation[] = [];
|
|
126
|
+
|
|
127
|
+
// Verify normative source hashes
|
|
128
|
+
const hashViolations = verifyNormativeSources(workspaceRoot, packet);
|
|
129
|
+
for (const v of hashViolations) {
|
|
130
|
+
violations.push({
|
|
131
|
+
rule: "PROGRAM-PACKET-07",
|
|
132
|
+
path: v.path,
|
|
133
|
+
message: `normative source hash mismatch: expected ${v.expected}, got ${v.actual}`,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Check git state
|
|
138
|
+
let branch = "";
|
|
139
|
+
let head = "";
|
|
140
|
+
try {
|
|
141
|
+
branch = gitBranch(workspaceRoot);
|
|
142
|
+
head = gitHead(workspaceRoot);
|
|
143
|
+
} catch {
|
|
144
|
+
violations.push({
|
|
145
|
+
rule: "PROGRAM-PACKET-02",
|
|
146
|
+
message: "failed to read git state",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check for active leases
|
|
151
|
+
const activeLeases = findActiveLeases(workspaceRoot, program);
|
|
152
|
+
const hasActiveLease = activeLeases.some(
|
|
153
|
+
(l) => l.packetId === packetId && !isLeaseStale(l.lease),
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
// Validate seal transition (covers most validation rules)
|
|
157
|
+
let isClean = false;
|
|
158
|
+
try {
|
|
159
|
+
isClean = gitIsClean(workspaceRoot);
|
|
160
|
+
} catch {
|
|
161
|
+
// ignore
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const sealViolations = validateSealTransition(manifest, entry, packet, {
|
|
165
|
+
branch,
|
|
166
|
+
head,
|
|
167
|
+
isClean,
|
|
168
|
+
hasActiveLease,
|
|
169
|
+
});
|
|
170
|
+
violations.push(...sealViolations);
|
|
171
|
+
|
|
172
|
+
const status = violations.length === 0 ? "pass" : "fail";
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
data: {
|
|
176
|
+
command: "program.packet.validate",
|
|
177
|
+
status,
|
|
178
|
+
program,
|
|
179
|
+
packetId,
|
|
180
|
+
phase,
|
|
181
|
+
branch,
|
|
182
|
+
head,
|
|
183
|
+
violations,
|
|
184
|
+
},
|
|
185
|
+
exitCode: status === "pass" ? 0 : 1,
|
|
186
|
+
summary:
|
|
187
|
+
status === "pass"
|
|
188
|
+
? `program.packet.validate: ${packetId} passed (${phase})`
|
|
189
|
+
: `program.packet.validate: ${packetId} failed with ${violations.length} violation(s)`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function failResult(
|
|
194
|
+
program: string,
|
|
195
|
+
packetId: string,
|
|
196
|
+
phase: string,
|
|
197
|
+
violations: PacketViolation[],
|
|
198
|
+
): ForgeCommandResult<ValidateResultData> {
|
|
199
|
+
return {
|
|
200
|
+
data: {
|
|
201
|
+
command: "program.packet.validate",
|
|
202
|
+
status: "fail",
|
|
203
|
+
program,
|
|
204
|
+
packetId,
|
|
205
|
+
phase,
|
|
206
|
+
branch: "",
|
|
207
|
+
head: "",
|
|
208
|
+
violations,
|
|
209
|
+
},
|
|
210
|
+
exitCode: 1,
|
|
211
|
+
summary: `program.packet.validate: ${packetId} failed with ${violations.length} violation(s)`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Untracked lease management for program packets. Lease files live
|
|
4
|
+
under .forge/program-leases/<program>/ and contain only the token hash.
|
|
5
|
+
Raw tokens are never persisted to disk.</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not store raw lease tokens — only the SHA-256 hash.</item>
|
|
8
|
+
<item>Do not import from @warpgogol/* — forge must remain dependency-free.</item>
|
|
9
|
+
</non-goals>
|
|
10
|
+
</MODULE_CONTRACT>
|
|
11
|
+
<CHANGE_SUMMARY>
|
|
12
|
+
<item>RFC-0856: initial lease management.</item>
|
|
13
|
+
</CHANGE_SUMMARY>
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
19
|
+
import { programPacketLeaseSchema, type ProgramPacketLease } from "./schemas.ts";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Lease directory
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export function leaseDir(workspaceRoot: string, program: string): string {
|
|
26
|
+
return path.join(workspaceRoot, ".forge", "program-leases", program);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function leasePath(workspaceRoot: string, program: string, packetId: string): string {
|
|
30
|
+
return path.join(leaseDir(workspaceRoot, program), `${packetId}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Token utilities
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate a random opaque lease token (32 bytes hex).
|
|
39
|
+
*/
|
|
40
|
+
export function generateLeaseToken(): string {
|
|
41
|
+
return randomBytes(32).toString("hex");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Hash a lease token for storage. Only the hash is persisted.
|
|
46
|
+
*/
|
|
47
|
+
export function hashLeaseToken(token: string): string {
|
|
48
|
+
return createHash("sha256").update(token).digest("hex");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Lease CRUD (untracked files)
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read a lease file. Returns null if no lease exists.
|
|
57
|
+
*/
|
|
58
|
+
export function readLease(
|
|
59
|
+
workspaceRoot: string,
|
|
60
|
+
program: string,
|
|
61
|
+
packetId: string,
|
|
62
|
+
): ProgramPacketLease | null {
|
|
63
|
+
const p = leasePath(workspaceRoot, program, packetId);
|
|
64
|
+
if (!fs.existsSync(p)) return null;
|
|
65
|
+
|
|
66
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
67
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
68
|
+
const result = programPacketLeaseSchema.safeParse(parsed);
|
|
69
|
+
if (!result.success) {
|
|
70
|
+
throw new Error(`PROGRAM-PACKET-01: malformed lease at ${p}: ${result.error.message}`);
|
|
71
|
+
}
|
|
72
|
+
return result.data;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Write a lease file atomically.
|
|
77
|
+
*/
|
|
78
|
+
export function writeLease(
|
|
79
|
+
workspaceRoot: string,
|
|
80
|
+
program: string,
|
|
81
|
+
lease: ProgramPacketLease,
|
|
82
|
+
): void {
|
|
83
|
+
const dir = leaseDir(workspaceRoot, program);
|
|
84
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
85
|
+
const p = leasePath(workspaceRoot, program, lease.packetId);
|
|
86
|
+
const content = JSON.stringify(lease, null, 2) + "\n";
|
|
87
|
+
fs.writeFileSync(p, content);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Delete a lease file.
|
|
92
|
+
*/
|
|
93
|
+
export function deleteLease(workspaceRoot: string, program: string, packetId: string): void {
|
|
94
|
+
const p = leasePath(workspaceRoot, program, packetId);
|
|
95
|
+
if (fs.existsSync(p)) {
|
|
96
|
+
fs.unlinkSync(p);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if any active lease exists for a program (any packet).
|
|
102
|
+
*/
|
|
103
|
+
export function findActiveLeases(
|
|
104
|
+
workspaceRoot: string,
|
|
105
|
+
program: string,
|
|
106
|
+
): Array<{ packetId: string; lease: ProgramPacketLease }> {
|
|
107
|
+
const dir = leaseDir(workspaceRoot, program);
|
|
108
|
+
if (!fs.existsSync(dir)) return [];
|
|
109
|
+
|
|
110
|
+
const results: Array<{ packetId: string; lease: ProgramPacketLease }> = [];
|
|
111
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
112
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
113
|
+
const p = path.join(dir, entry.name);
|
|
114
|
+
const raw = fs.readFileSync(p, "utf8");
|
|
115
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
116
|
+
const result = programPacketLeaseSchema.safeParse(parsed);
|
|
117
|
+
if (result.success) {
|
|
118
|
+
results.push({
|
|
119
|
+
packetId: result.data.packetId,
|
|
120
|
+
lease: result.data,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Check if a lease is stale (heartbeat exceeded timeout).
|
|
129
|
+
*/
|
|
130
|
+
export function isLeaseStale(lease: ProgramPacketLease, now: Date = new Date()): boolean {
|
|
131
|
+
const heartbeatMs = Date.parse(lease.heartbeatAt);
|
|
132
|
+
if (Number.isNaN(heartbeatMs)) return true;
|
|
133
|
+
const elapsed = now.getTime() - heartbeatMs;
|
|
134
|
+
return elapsed > lease.timeoutSeconds * 1000;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Update heartbeat timestamp on an existing lease.
|
|
139
|
+
*/
|
|
140
|
+
export function updateHeartbeat(
|
|
141
|
+
workspaceRoot: string,
|
|
142
|
+
program: string,
|
|
143
|
+
packetId: string,
|
|
144
|
+
now: Date = new Date(),
|
|
145
|
+
): ProgramPacketLease {
|
|
146
|
+
const existing = readLease(workspaceRoot, program, packetId);
|
|
147
|
+
if (!existing) {
|
|
148
|
+
throw new Error(`PROGRAM-PACKET-05: no lease found for ${packetId}`);
|
|
149
|
+
}
|
|
150
|
+
const updated: ProgramPacketLease = {
|
|
151
|
+
...existing,
|
|
152
|
+
heartbeatAt: now.toISOString(),
|
|
153
|
+
};
|
|
154
|
+
writeLease(workspaceRoot, program, updated);
|
|
155
|
+
return updated;
|
|
156
|
+
}
|