@pmelab/gtd 27.7.1 → 27.9.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/.claude-plugin/plugin.json +1 -1
- package/README.md +10 -5
- package/dist/chunks/{wire-C12dahrH.mjs → wire-D3Rc5r22.mjs} +14 -1
- package/dist/flows.mjs +16 -1
- package/dist/gtd.bundle.mjs +203 -32
- package/dist/testkit.mjs +172 -33
- package/dist/wire.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +39 -3
- package/src/cli/Cli.ts +23 -2
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +22 -0
- package/src/edge/Edge.test.ts +217 -34
- package/src/edge/Edge.ts +131 -39
- package/src/edge/index.ts +1 -0
- package/src/flows/runtime.test.ts +27 -0
- package/src/flows/runtime.ts +18 -0
- package/src/replay/Replay.test.ts +169 -0
- package/src/replay/Replay.ts +42 -2
- package/src/replay/Slices.test.ts +73 -0
- package/src/replay/Slices.ts +45 -0
- package/src/replay/Trailers.test.ts +11 -0
- package/src/replay/Trailers.ts +3 -1
- package/src/replay/index.ts +1 -0
- package/src/wire/Slices.ts +25 -0
- package/src/wire/WireSchema.test.ts +18 -0
- package/src/wire/golden/1/slices.json +11 -0
- package/src/wire/index.ts +1 -0
package/src/edge/Edge.test.ts
CHANGED
|
@@ -3,10 +3,10 @@ import { describe, expect, it } from "vitest"
|
|
|
3
3
|
import { runCli } from "../cli/index.js"
|
|
4
4
|
import {
|
|
5
5
|
currentRest,
|
|
6
|
-
closedRunAt,
|
|
7
6
|
currentRun,
|
|
8
7
|
historyTrail,
|
|
9
8
|
noProcessUnderway,
|
|
9
|
+
processSlices,
|
|
10
10
|
renderRest,
|
|
11
11
|
restAt,
|
|
12
12
|
restIsIdle,
|
|
@@ -596,6 +596,171 @@ export const summary = (it) => it.marks.map((m) => m.name + "=" + m.value).join(
|
|
|
596
596
|
})
|
|
597
597
|
})
|
|
598
598
|
|
|
599
|
+
const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
|
|
600
|
+
const real = fakeGitOperations(repo)
|
|
601
|
+
const head = repo.resolveRef("HEAD")
|
|
602
|
+
return {
|
|
603
|
+
...real,
|
|
604
|
+
commitHistory: (base, tip) =>
|
|
605
|
+
Effect.map(real.commitHistory(base, tip), (commits) =>
|
|
606
|
+
commits.map((c) => (c.hash === head ? { ...c, message: edit(c.message) } : c)),
|
|
607
|
+
),
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
describe("processSlices", () => {
|
|
612
|
+
const failure = async (repo: InMemRepo): Promise<string> => {
|
|
613
|
+
const exit = await provideExit(processSlices, repo)
|
|
614
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
615
|
+
return Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
it("never reports an open slice for a closed run whose replay no longer ends", async () => {
|
|
619
|
+
const repo = repoWith(LINEAR)
|
|
620
|
+
await land(repo, { "a.txt": "a\n" })
|
|
621
|
+
await land(repo, { "b.txt": "b\n" })
|
|
622
|
+
await land(repo, { "c.txt": "c\n" })
|
|
623
|
+
repo.writeFile(
|
|
624
|
+
"gtd.config.ts",
|
|
625
|
+
LINEAR.replace(
|
|
626
|
+
'await agent("checking", "check-prompt")',
|
|
627
|
+
'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
|
|
628
|
+
),
|
|
629
|
+
)
|
|
630
|
+
// Stands for a flow change with no in-repo config edit (an upgraded
|
|
631
|
+
// out-of-repo import): every ref reads as the working tree, so the
|
|
632
|
+
// config-differs refusal stays quiet and only replay drift remains.
|
|
633
|
+
const git: GitOperations = {
|
|
634
|
+
...fakeGitOperations(repo),
|
|
635
|
+
readFileAtRef: (_ref, path) => Effect.succeed(repo.readFile(path) ?? ""),
|
|
636
|
+
}
|
|
637
|
+
const exit = await Effect.runPromiseExit(
|
|
638
|
+
processSlices.pipe(
|
|
639
|
+
Effect.provideService(GitService, git),
|
|
640
|
+
Effect.provide(testLayers(repo, {})),
|
|
641
|
+
),
|
|
642
|
+
)
|
|
643
|
+
expect(Exit.isFailure(exit)).toBe(true)
|
|
644
|
+
const message = Exit.isFailure(exit) ? String(exit.cause) : ""
|
|
645
|
+
expect(message).toContain(
|
|
646
|
+
`gtd slices: refused — ${repo.resolveRef("HEAD")!.slice(0, 7)} closes a process that does not end under today's config`,
|
|
647
|
+
)
|
|
648
|
+
})
|
|
649
|
+
|
|
650
|
+
it("never reports a closed slice for an underway run whose replay now ends", async () => {
|
|
651
|
+
const repo = repoWith(LINEAR)
|
|
652
|
+
await land(repo, { "a.txt": "a\n" })
|
|
653
|
+
await land(repo, { "b.txt": "b\n" })
|
|
654
|
+
repo.writeFile(
|
|
655
|
+
"gtd.config.ts",
|
|
656
|
+
LINEAR.replace(' await agent("checking", "check-prompt")\n', ""),
|
|
657
|
+
)
|
|
658
|
+
const message = await failure(repo)
|
|
659
|
+
expect(message).toContain("gtd slices: refused")
|
|
660
|
+
expect(message).toContain("the workflow changed under this process")
|
|
661
|
+
expect(message).toContain("gtd abandon")
|
|
662
|
+
expect(message).not.toContain("gtd slices: gtd:")
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
it("refuses with no process at HEAD, naming that HEAD neither closes nor sits inside one", async () => {
|
|
666
|
+
const repo = repoWith(LINEAR)
|
|
667
|
+
expect(await failure(repo)).toContain(
|
|
668
|
+
"gtd slices: refused — HEAD neither closes nor sits inside a process",
|
|
669
|
+
)
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
it("refuses a renamed step with replay's message, prefixed and without a gtd: lead", async () => {
|
|
673
|
+
const repo = repoWith(LINEAR)
|
|
674
|
+
await land(repo, { "a.txt": "a\n" })
|
|
675
|
+
repo.writeFile("gtd.config.ts", LINEAR.replace('agent("building"', 'agent("constructing"'))
|
|
676
|
+
const message = await failure(repo)
|
|
677
|
+
expect(message).toContain("gtd slices: refused — ")
|
|
678
|
+
expect(message).not.toContain("HEAD neither closes nor sits inside a process")
|
|
679
|
+
expect(message).not.toContain("refused — gtd: ")
|
|
680
|
+
expect(message).not.toContain("gtd slices: gtd:")
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
it("refuses a finished process whose config file was edited since", async () => {
|
|
684
|
+
const repo = repoWith(LINEAR)
|
|
685
|
+
await land(repo, { "a.txt": "a\n" })
|
|
686
|
+
await land(repo, { "b.txt": "b\n" })
|
|
687
|
+
await land(repo, { "c.txt": "c\n" })
|
|
688
|
+
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
689
|
+
const message = await failure(repo)
|
|
690
|
+
expect(message).toContain(
|
|
691
|
+
`gtd slices: refused — config differs at ${repo.resolveRef("HEAD")!.slice(0, 7)}: gtd.config.ts. HEAD's process replays under today's config; revert or stash the edit to list its slices`,
|
|
692
|
+
)
|
|
693
|
+
})
|
|
694
|
+
|
|
695
|
+
const finishedProcess = async () => {
|
|
696
|
+
const repo = repoWith(LINEAR)
|
|
697
|
+
await land(repo, { "a.txt": "a\n" })
|
|
698
|
+
await land(repo, { "b.txt": "b\n" })
|
|
699
|
+
await land(repo, { "c.txt": "c\n" })
|
|
700
|
+
return repo
|
|
701
|
+
}
|
|
702
|
+
const slicesWith = (repo: InMemRepo, git: GitOperations) =>
|
|
703
|
+
Effect.runPromise(
|
|
704
|
+
processSlices.pipe(
|
|
705
|
+
Effect.provideService(GitService, git),
|
|
706
|
+
Effect.provide(testLayers(repo, {})),
|
|
707
|
+
),
|
|
708
|
+
)
|
|
709
|
+
|
|
710
|
+
it("warns about a closed run recorded by another version, slices unchanged", async () => {
|
|
711
|
+
const repo = await finishedProcess()
|
|
712
|
+
const plain = await provide(processSlices, repo)
|
|
713
|
+
const git = withTipMessage(repo, (m) =>
|
|
714
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
|
|
715
|
+
)
|
|
716
|
+
const result = await slicesWith(repo, git)
|
|
717
|
+
expect(result.versionWarning).toContain("0.0.1")
|
|
718
|
+
expect(result.versionWarning).toContain(GTD_VERSION)
|
|
719
|
+
expect(result.slices).toEqual(plain.slices)
|
|
720
|
+
})
|
|
721
|
+
|
|
722
|
+
it("warns about an unknown version when the closing commit records none", async () => {
|
|
723
|
+
const repo = await finishedProcess()
|
|
724
|
+
const git = withTipMessage(repo, (m) => m.replace(/Gtd-Version: .*\n?/, ""))
|
|
725
|
+
expect((await slicesWith(repo, git)).versionWarning).toContain("an unknown gtd version")
|
|
726
|
+
})
|
|
727
|
+
|
|
728
|
+
it("does not warn for a closed run landed at the running version", async () => {
|
|
729
|
+
const repo = await finishedProcess()
|
|
730
|
+
expect((await provide(processSlices, repo)).versionWarning).toBeUndefined()
|
|
731
|
+
})
|
|
732
|
+
|
|
733
|
+
it("does not warn for an underway process", async () => {
|
|
734
|
+
const repo = repoWith(LINEAR)
|
|
735
|
+
await land(repo, { "a.txt": "a\n" })
|
|
736
|
+
const git = withTipMessage(repo, (m) =>
|
|
737
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, "Gtd-Version: 0.0.1"),
|
|
738
|
+
)
|
|
739
|
+
expect((await slicesWith(repo, git)).versionWarning).toBeUndefined()
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
it("refuses on config drift before replay-ends drift", async () => {
|
|
743
|
+
const repo = await finishedProcess()
|
|
744
|
+
repo.writeFile(
|
|
745
|
+
"gtd.config.ts",
|
|
746
|
+
LINEAR.replace(
|
|
747
|
+
'await agent("checking", "check-prompt")',
|
|
748
|
+
'await agent("checking", "check-prompt")\n await human("idle", { message: "again" })\n await agent("polishing", "polish-prompt")',
|
|
749
|
+
),
|
|
750
|
+
)
|
|
751
|
+
const message = await failure(repo)
|
|
752
|
+
expect(message).toContain("config differs at")
|
|
753
|
+
expect(message).not.toContain("does not end")
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
it("still lists an underway process's slices after the same config edit", async () => {
|
|
757
|
+
const repo = repoWith(LINEAR)
|
|
758
|
+
await land(repo, { "a.txt": "a\n" })
|
|
759
|
+
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
760
|
+
expect((await provide(processSlices, repo)).slices).toHaveLength(1)
|
|
761
|
+
})
|
|
762
|
+
})
|
|
763
|
+
|
|
599
764
|
describe("restAt", () => {
|
|
600
765
|
it("restAt(ref) resolves where the process rested at that commit", async () => {
|
|
601
766
|
const repo = repoWith(LINEAR)
|
|
@@ -1126,30 +1291,6 @@ describe("the pending working tree is read at most once per restAt, and never st
|
|
|
1126
1291
|
})
|
|
1127
1292
|
})
|
|
1128
1293
|
|
|
1129
|
-
describe("closedRunAt", () => {
|
|
1130
|
-
it("is the run summaryRun reads when HEAD is the tip", async () => {
|
|
1131
|
-
const repo = repoWith(LINEAR)
|
|
1132
|
-
await land(repo, { "a.txt": "a\n" })
|
|
1133
|
-
await land(repo, { "b.txt": "b\n" })
|
|
1134
|
-
const tip = await land(repo, { "c.txt": "c\n" })
|
|
1135
|
-
expect(await provide(closedRunAt(tip), repo)).toEqual(await provide(summaryRun, repo))
|
|
1136
|
-
})
|
|
1137
|
-
|
|
1138
|
-
it("resolves the closed run with HEAD elsewhere", async () => {
|
|
1139
|
-
const repo = repoWith(LINEAR)
|
|
1140
|
-
const building = await land(repo, { "a.txt": "a\n" })
|
|
1141
|
-
await land(repo, { "b.txt": "b\n" })
|
|
1142
|
-
const tip = await land(repo, { "c.txt": "c\n" })
|
|
1143
|
-
const closed = await provide(closedRunAt(tip), repo)
|
|
1144
|
-
repo.writeFile("d.txt", "d\n")
|
|
1145
|
-
repo.seedCommit("chore: unrelated")
|
|
1146
|
-
const later = await provide(closedRunAt(tip), repo)
|
|
1147
|
-
expect(later).toEqual(closed)
|
|
1148
|
-
expect(later.closingHash).toBe(tip)
|
|
1149
|
-
expect(later.startHash).toBe(building)
|
|
1150
|
-
})
|
|
1151
|
-
})
|
|
1152
|
-
|
|
1153
1294
|
describe("finishing landings keep history", () => {
|
|
1154
1295
|
const landingOf = async (repo: InMemRepo, git?: GitOperations) => {
|
|
1155
1296
|
const rest = await provide(currentRest, repo)
|
|
@@ -1439,14 +1580,6 @@ export default async () => {
|
|
|
1439
1580
|
expect(trail.versionWarning).toBeUndefined()
|
|
1440
1581
|
})
|
|
1441
1582
|
|
|
1442
|
-
const withTipMessage = (repo: InMemRepo, edit: (message: string) => string): GitOperations => {
|
|
1443
|
-
const real = fakeGitOperations(repo)
|
|
1444
|
-
return {
|
|
1445
|
-
...real,
|
|
1446
|
-
lastCommitMessage: (ref) => Effect.map(real.lastCommitMessage(ref), edit),
|
|
1447
|
-
}
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
1583
|
it("warns, never refuses, when the recorded version differs", async () => {
|
|
1451
1584
|
const { repo, tip } = await finished()
|
|
1452
1585
|
const git = withTipMessage(repo, (m) =>
|
|
@@ -1476,13 +1609,63 @@ export default async () => {
|
|
|
1476
1609
|
expect(trail.versionWarning).toContain(GTD_VERSION)
|
|
1477
1610
|
})
|
|
1478
1611
|
|
|
1612
|
+
it("reads a recorded version carrying control bytes as unknown", async () => {
|
|
1613
|
+
const { repo, tip } = await finished()
|
|
1614
|
+
const raw = "1.0\u001b]0;x\u0007"
|
|
1615
|
+
const git = withTipMessage(repo, (m) =>
|
|
1616
|
+
m.replace(`Gtd-Version: ${GTD_VERSION}`, `Gtd-Version: ${raw}`),
|
|
1617
|
+
)
|
|
1618
|
+
const trail = await Effect.runPromise(
|
|
1619
|
+
historyTrail(tip).pipe(
|
|
1620
|
+
Effect.provideService(GitService, git),
|
|
1621
|
+
Effect.provide(testLayers(repo, {})),
|
|
1622
|
+
),
|
|
1623
|
+
)
|
|
1624
|
+
expect(trail.version.recorded).toBeUndefined()
|
|
1625
|
+
expect(trail.versionWarning).toContain("an unknown gtd version")
|
|
1626
|
+
// eslint-disable-next-line no-control-regex
|
|
1627
|
+
expect(trail.versionWarning).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/)
|
|
1628
|
+
})
|
|
1629
|
+
|
|
1479
1630
|
it("refuses naming the config file that differs at the tip", async () => {
|
|
1480
1631
|
const { repo, tip } = await finished()
|
|
1481
1632
|
repo.writeFile("gtd.config.ts", `${LINEAR}\n// edited\n`)
|
|
1482
1633
|
repo.seedCommit("chore: edit config")
|
|
1483
1634
|
const exit = await provideExit(historyTrail(tip), repo)
|
|
1484
1635
|
expect(Exit.isFailure(exit)).toBe(true)
|
|
1485
|
-
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1636
|
+
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1637
|
+
`gtd history: refused — config differs at ${tip.slice(0, 7)}: gtd.config.ts. A history ref replays under today's config, so it is unreadable after the config changes`,
|
|
1638
|
+
)
|
|
1639
|
+
})
|
|
1640
|
+
|
|
1641
|
+
it("refuses a resting tip with the exact message", async () => {
|
|
1642
|
+
const repo = repoWith(LINEAR)
|
|
1643
|
+
const tip = await land(repo, { "a.txt": "a\n" })
|
|
1644
|
+
const exit = await provideExit(historyTrail(tip), repo)
|
|
1645
|
+
expect(String(Exit.isFailure(exit) && exit.cause)).toContain(
|
|
1646
|
+
`gtd history: ${tip.slice(0, 7)} does not close a process under today's config`,
|
|
1647
|
+
)
|
|
1648
|
+
})
|
|
1649
|
+
|
|
1650
|
+
it("reads the episode's trees exactly once", async () => {
|
|
1651
|
+
const { repo, tip } = await finished()
|
|
1652
|
+
let calls = 0
|
|
1653
|
+
const trail = await Effect.runPromise(
|
|
1654
|
+
Effect.gen(function* () {
|
|
1655
|
+
const real = yield* Workspace
|
|
1656
|
+
return yield* historyTrail(tip).pipe(
|
|
1657
|
+
Effect.provideService(Workspace, {
|
|
1658
|
+
...real,
|
|
1659
|
+
episodeTrees: (...args: Parameters<typeof real.episodeTrees>) => {
|
|
1660
|
+
calls++
|
|
1661
|
+
return real.episodeTrees(...args)
|
|
1662
|
+
},
|
|
1663
|
+
}),
|
|
1664
|
+
)
|
|
1665
|
+
}).pipe(Effect.provide(testLayers(repo, {}))),
|
|
1666
|
+
)
|
|
1667
|
+
expect(trail.steps).toHaveLength(3)
|
|
1668
|
+
expect(calls).toBe(1)
|
|
1486
1669
|
})
|
|
1487
1670
|
|
|
1488
1671
|
it("refuses naming a config file missing from the tip", async () => {
|
package/src/edge/Edge.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
isRecordableApproval,
|
|
38
38
|
parseCommitMessage,
|
|
39
39
|
replay,
|
|
40
|
+
sliceRanges,
|
|
40
41
|
treeFromRecord,
|
|
41
42
|
type EpisodeCommit,
|
|
42
43
|
type JudgeVerdict,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
UNATTRIBUTED_MODEL,
|
|
52
53
|
type ApprovalEntry,
|
|
53
54
|
type ModelCost,
|
|
55
|
+
type SliceEntry,
|
|
54
56
|
type StepAccess,
|
|
55
57
|
} from "../wire/index.js"
|
|
56
58
|
|
|
@@ -474,26 +476,21 @@ const runAtTip = (
|
|
|
474
476
|
return yield* runOf([...before, ...closing], { ...location }, tip.hash)
|
|
475
477
|
})
|
|
476
478
|
|
|
479
|
+
const runAtHead = (git: GitOperations, def: WorkflowDefinition) =>
|
|
480
|
+
Effect.gen(function* () {
|
|
481
|
+
// Cheap: just HEAD's own subject, never the commits before it.
|
|
482
|
+
return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
|
|
483
|
+
})
|
|
484
|
+
|
|
477
485
|
/** The process HEAD closes or sits inside — `gtd summary`'s run. */
|
|
478
486
|
export const summaryRun: Effect.Effect<ProcessRun, Error, ConfigRequirements> = Effect.gen(
|
|
479
487
|
function* () {
|
|
480
488
|
const git = yield* GitService
|
|
481
489
|
const def = (yield* (yield* ConfigService).load).workflow
|
|
482
|
-
|
|
483
|
-
return yield* runAtTip(git, def, (yield* git.subjectHistory(1, 0))[0])
|
|
490
|
+
return yield* runAtHead(git, def)
|
|
484
491
|
},
|
|
485
492
|
)
|
|
486
493
|
|
|
487
|
-
/** The process whose finishing commit is `tip` — reads from `tip`'s own ancestry, so it works when no branch reaches it. */
|
|
488
|
-
export const closedRunAt = (tip: string): Effect.Effect<ProcessRun, Error, ConfigRequirements> =>
|
|
489
|
-
Effect.gen(function* () {
|
|
490
|
-
const git = yield* GitService
|
|
491
|
-
const def = (yield* (yield* ConfigService).load).workflow
|
|
492
|
-
const hash = yield* git.resolveRef(tip)
|
|
493
|
-
const subject = yield* git.lastCommitSubject(hash)
|
|
494
|
-
return yield* runAtTip(git, def, { hash, subject })
|
|
495
|
-
})
|
|
496
|
-
|
|
497
494
|
// ── Trees ───────────────────────────────────────────────────────────────────
|
|
498
495
|
|
|
499
496
|
const commitTree = (workspace: WorkspaceOps, hash: string): TreeView => {
|
|
@@ -987,6 +984,15 @@ const pendingChanges = (git: GitOperations, withIndex: boolean) =>
|
|
|
987
984
|
return [...worktree, ...(yield* git.stagedPaths()).filter((e) => !seen.has(e.path))]
|
|
988
985
|
})
|
|
989
986
|
|
|
987
|
+
const sliceRangesOf = (
|
|
988
|
+
slices: Parameters<typeof sliceRanges>[0],
|
|
989
|
+
run: { readonly startParentHash: string; readonly trace: readonly { readonly hash: string }[] },
|
|
990
|
+
) =>
|
|
991
|
+
sliceRanges(slices, {
|
|
992
|
+
base: run.startParentHash,
|
|
993
|
+
tip: run.trace.at(-1)?.hash ?? run.startParentHash,
|
|
994
|
+
})
|
|
995
|
+
|
|
990
996
|
/** Resolve the rest at `ref`, or at HEAD when `ref` is `undefined`. */
|
|
991
997
|
export const restAt = (
|
|
992
998
|
ref: string | undefined,
|
|
@@ -1065,6 +1071,69 @@ export const restAt = (
|
|
|
1065
1071
|
|
|
1066
1072
|
export const currentRest: Effect.Effect<Rest, Error, RestRequirements> = restAt(undefined)
|
|
1067
1073
|
|
|
1074
|
+
export interface ProcessSlices {
|
|
1075
|
+
readonly slices: readonly SliceEntry[]
|
|
1076
|
+
readonly versionWarning?: string
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* The slices of the process HEAD closes or sits inside, current episode only,
|
|
1081
|
+
* stack order. Reads like `historyTrail`, never `restAt`: it narrates and
|
|
1082
|
+
* writes nothing, and returns a version warning for a closed run.
|
|
1083
|
+
*/
|
|
1084
|
+
export const processSlices: Effect.Effect<ProcessSlices, Error, RestRequirements> = Effect.gen(
|
|
1085
|
+
function* () {
|
|
1086
|
+
const git = yield* GitService
|
|
1087
|
+
const config = yield* (yield* ConfigService).load
|
|
1088
|
+
const workspace = yield* Workspace
|
|
1089
|
+
const host = yield* Host
|
|
1090
|
+
const run = yield* runAtHead(git, config.workflow)
|
|
1091
|
+
if (run.trace.length === 0) {
|
|
1092
|
+
return yield* Effect.fail(
|
|
1093
|
+
new Error("gtd slices: refused — HEAD neither closes nor sits inside a process"),
|
|
1094
|
+
)
|
|
1095
|
+
}
|
|
1096
|
+
const closing = run.closingHash
|
|
1097
|
+
let outcome: ReplayOutcome
|
|
1098
|
+
let versionWarning: string | undefined
|
|
1099
|
+
if (closing !== undefined) {
|
|
1100
|
+
const replayed = yield* replayFinishedProcess(config, run, closing, {
|
|
1101
|
+
command: "gtd slices",
|
|
1102
|
+
configReason:
|
|
1103
|
+
"HEAD's process replays under today's config; revert or stash the edit to list its slices",
|
|
1104
|
+
notEnded: (o) => slicesDivergence(run, o),
|
|
1105
|
+
})
|
|
1106
|
+
outcome = replayed.outcome
|
|
1107
|
+
versionWarning = replayed.versionWarning
|
|
1108
|
+
} else {
|
|
1109
|
+
const { setup } = yield* setupFor(config, run, workspace, host)
|
|
1110
|
+
outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1111
|
+
if (outcome.kind !== "rest") return yield* Effect.fail(slicesDivergence(run, outcome))
|
|
1112
|
+
}
|
|
1113
|
+
return {
|
|
1114
|
+
slices: sliceRangesOf(outcome.slices, run).map(({ name, base, tip, open }) => ({
|
|
1115
|
+
name,
|
|
1116
|
+
base,
|
|
1117
|
+
tip,
|
|
1118
|
+
closed: closing !== undefined || !open,
|
|
1119
|
+
})),
|
|
1120
|
+
...(versionWarning !== undefined ? { versionWarning } : {}),
|
|
1121
|
+
}
|
|
1122
|
+
},
|
|
1123
|
+
)
|
|
1124
|
+
|
|
1125
|
+
type EndedOutcome = Extract<ReplayOutcome, { kind: "ended" }>
|
|
1126
|
+
type UnendedOutcome = Exclude<ReplayOutcome, { kind: "ended" }>
|
|
1127
|
+
|
|
1128
|
+
/** Every `gtd slices` refusal reads `gtd slices: refused — <reason>`; replay's own `gtd: ` lead is dropped. */
|
|
1129
|
+
const slicesDivergence = (run: ProcessRun, outcome: ReplayOutcome): Error => {
|
|
1130
|
+
const reason =
|
|
1131
|
+
outcome.kind === "rest" && run.closingHash !== undefined
|
|
1132
|
+
? `${run.closingHash.slice(0, 7)} closes a process that does not end under today's config`
|
|
1133
|
+
: (replayError(outcome)?.message.replace(/^gtd: /, "") ?? "replay failed")
|
|
1134
|
+
return new Error(`gtd slices: refused — ${reason}`)
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1068
1137
|
/**
|
|
1069
1138
|
* Whether `gtd --workflow <name>` can start a process here: the workflow must
|
|
1070
1139
|
* reach a step from the tree the opening commit would capture. That step is
|
|
@@ -1218,7 +1287,7 @@ const multilineRefusal = (vars: Record<string, string>): Landing | undefined =>
|
|
|
1218
1287
|
}
|
|
1219
1288
|
|
|
1220
1289
|
const landingTarget = (
|
|
1221
|
-
outcome:
|
|
1290
|
+
outcome: ReplayOutcome,
|
|
1222
1291
|
rest: Rest,
|
|
1223
1292
|
): Effect.Effect<StateName | Landing, Error> => {
|
|
1224
1293
|
if (outcome.kind === "refused")
|
|
@@ -1429,10 +1498,14 @@ const inRepoConfigPaths = (
|
|
|
1429
1498
|
return [...paths].filter((p) => p !== "" && !p.startsWith("..") && !isAbsolute(p)).sort()
|
|
1430
1499
|
}
|
|
1431
1500
|
|
|
1432
|
-
const configRefusal = (
|
|
1501
|
+
const configRefusal = (
|
|
1502
|
+
command: string,
|
|
1503
|
+
tip: string,
|
|
1504
|
+
paths: readonly string[],
|
|
1505
|
+
reason = "A history ref replays under today's config, so it is unreadable after the config changes",
|
|
1506
|
+
): Error =>
|
|
1433
1507
|
new Error(
|
|
1434
|
-
|
|
1435
|
-
`A history ref replays under today's config, so it is unreadable after the config changes`,
|
|
1508
|
+
`${command}: refused — config differs at ${tip.slice(0, 7)}: ${paths.join(", ")}. ${reason}`,
|
|
1436
1509
|
)
|
|
1437
1510
|
|
|
1438
1511
|
const differingConfigPaths = (
|
|
@@ -1456,10 +1529,7 @@ const differingConfigPaths = (
|
|
|
1456
1529
|
return differing
|
|
1457
1530
|
})
|
|
1458
1531
|
|
|
1459
|
-
const notClosedError = (
|
|
1460
|
-
hash: string,
|
|
1461
|
-
outcome: Exclude<Awaited<ReturnType<typeof replayFor>>, { kind: "ended" }>,
|
|
1462
|
-
): Error =>
|
|
1532
|
+
const notClosedError = (hash: string, outcome: UnendedOutcome): Error =>
|
|
1463
1533
|
outcome.kind === "rest"
|
|
1464
1534
|
? new Error(`gtd history: ${hash.slice(0, 7)} does not close a process under today's config`)
|
|
1465
1535
|
: (replayError(outcome) ?? new Error("gtd history: replay failed"))
|
|
@@ -1494,30 +1564,52 @@ export const historyTrail = (tip: string): Effect.Effect<HistoryTrail, Error, Re
|
|
|
1494
1564
|
Effect.gen(function* () {
|
|
1495
1565
|
const git = yield* GitService
|
|
1496
1566
|
const config = yield* (yield* ConfigService).load
|
|
1497
|
-
const workspace = yield* Workspace
|
|
1498
|
-
const host = yield* Host
|
|
1499
1567
|
const hash = yield* git.resolveRef(tip)
|
|
1500
|
-
const
|
|
1501
|
-
|
|
1502
|
-
const
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
yield* workspace.episodeTrees(
|
|
1507
|
-
run.episode.base,
|
|
1508
|
-
run.episode.commits.map((c) => c.hash),
|
|
1509
|
-
)
|
|
1510
|
-
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1511
|
-
if (outcome.kind !== "ended") return yield* Effect.fail(notClosedError(hash, outcome))
|
|
1512
|
-
|
|
1513
|
-
const steps = historySteps(run)
|
|
1514
|
-
const recorded = parseCommitMessage(yield* git.lastCommitMessage(hash)).version
|
|
1515
|
-
const versionWarning = versionWarningFor(recorded)
|
|
1568
|
+
const subject = yield* git.lastCommitSubject(hash)
|
|
1569
|
+
const run = yield* runAtTip(git, config.workflow, { hash, subject })
|
|
1570
|
+
const { outcome, recorded, versionWarning } = yield* replayFinishedProcess(config, run, hash, {
|
|
1571
|
+
command: "gtd history",
|
|
1572
|
+
notEnded: (o) => notClosedError(hash, o),
|
|
1573
|
+
})
|
|
1516
1574
|
return {
|
|
1517
1575
|
tip: hash,
|
|
1518
|
-
steps,
|
|
1576
|
+
steps: historySteps(run),
|
|
1519
1577
|
ended: outcome.via,
|
|
1520
1578
|
version: { recorded, running: GTD_VERSION },
|
|
1521
1579
|
...(versionWarning !== undefined ? { versionWarning } : {}),
|
|
1522
1580
|
}
|
|
1523
1581
|
})
|
|
1582
|
+
|
|
1583
|
+
interface FinishedReplay {
|
|
1584
|
+
readonly outcome: EndedOutcome
|
|
1585
|
+
readonly recorded: string | undefined
|
|
1586
|
+
readonly versionWarning?: string
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
/** Refuses on config drift before replaying, so a stale config never runs the flow. */
|
|
1590
|
+
const replayFinishedProcess = (
|
|
1591
|
+
config: ConfigOperations,
|
|
1592
|
+
run: ProcessRun,
|
|
1593
|
+
hash: string,
|
|
1594
|
+
how: {
|
|
1595
|
+
readonly command: string
|
|
1596
|
+
readonly configReason?: string
|
|
1597
|
+
readonly notEnded: (outcome: UnendedOutcome) => Error
|
|
1598
|
+
},
|
|
1599
|
+
): Effect.Effect<FinishedReplay, Error, RestRequirements> =>
|
|
1600
|
+
Effect.gen(function* () {
|
|
1601
|
+
const git = yield* GitService
|
|
1602
|
+
const workspace = yield* Workspace
|
|
1603
|
+
const host = yield* Host
|
|
1604
|
+
const differing = yield* differingConfigPaths(git, workspace, host.root, config, run, hash)
|
|
1605
|
+
if (differing.length > 0)
|
|
1606
|
+
return yield* Effect.fail(configRefusal(how.command, hash, differing, how.configReason))
|
|
1607
|
+
const { setup } = yield* setupFor(config, run, workspace, host)
|
|
1608
|
+
const outcome = yield* Effect.promise(() => replayFor(setup))
|
|
1609
|
+
if (outcome.kind !== "ended") return yield* Effect.fail(how.notEnded(outcome))
|
|
1610
|
+
const recorded = parseCommitMessage(
|
|
1611
|
+
run.episode.commits.find((c) => c.hash === hash)?.message ?? "",
|
|
1612
|
+
).version
|
|
1613
|
+
const versionWarning = versionWarningFor(recorded)
|
|
1614
|
+
return { outcome, recorded, ...(versionWarning !== undefined ? { versionWarning } : {}) }
|
|
1615
|
+
})
|
package/src/edge/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
kept,
|
|
8
8
|
mark,
|
|
9
9
|
protocolMismatch,
|
|
10
|
+
slice,
|
|
10
11
|
type FlowContext,
|
|
11
12
|
} from "./runtime.js"
|
|
12
13
|
|
|
@@ -127,3 +128,29 @@ describe("kept()", () => {
|
|
|
127
128
|
expect(FLOWS_PROTOCOL).toBe(1)
|
|
128
129
|
})
|
|
129
130
|
})
|
|
131
|
+
|
|
132
|
+
describe("slice()", () => {
|
|
133
|
+
const withSlices = () => {
|
|
134
|
+
const calls: string[] = []
|
|
135
|
+
installContext({
|
|
136
|
+
enterSlice: (n: string) => calls.push(`enter ${n}`),
|
|
137
|
+
leaveSlice: () => calls.push("leave"),
|
|
138
|
+
} as unknown as FlowContext)
|
|
139
|
+
return calls
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
it("brackets fn with enter and leave and resolves to its value", async () => {
|
|
143
|
+
const calls = withSlices()
|
|
144
|
+
const value = await slice("refactor", async () => {
|
|
145
|
+
calls.push("fn")
|
|
146
|
+
return 42
|
|
147
|
+
})
|
|
148
|
+
expect(value).toBe(42)
|
|
149
|
+
expect(calls).toEqual(["enter refactor", "fn", "leave"])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("runs fn unsliced on a context without enterSlice", async () => {
|
|
153
|
+
installContext(context)
|
|
154
|
+
expect(await slice("refactor", async () => "ran")).toBe("ran")
|
|
155
|
+
})
|
|
156
|
+
})
|
package/src/flows/runtime.ts
CHANGED
|
@@ -150,6 +150,9 @@ export interface FlowContext {
|
|
|
150
150
|
readonly refuse: (message: string) => never
|
|
151
151
|
readonly pushScope: (scope: ScopeOptions) => void
|
|
152
152
|
readonly popScope: () => void
|
|
153
|
+
/** Absent on an engine older than `slice`; FLOWS_PROTOCOL stays 1. */
|
|
154
|
+
readonly enterSlice?: (name: string) => void
|
|
155
|
+
readonly leaveSlice?: () => void
|
|
153
156
|
readonly read: (path: string) => string | undefined
|
|
154
157
|
readonly glob: (pattern: string) => readonly string[]
|
|
155
158
|
/** The last step's changes, content read on demand. */
|
|
@@ -284,6 +287,21 @@ export const scope = async <T>(scope: string | ScopeOptions, fn: () => Promise<T
|
|
|
284
287
|
}
|
|
285
288
|
}
|
|
286
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Mark the span `fn` covers as one named slice of the process. Lands no
|
|
292
|
+
* commit, prefixes no step name, opens no memory scope.
|
|
293
|
+
*/
|
|
294
|
+
export const slice = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
|
|
295
|
+
const context = ctx()
|
|
296
|
+
if (typeof context.enterSlice !== "function") return fn()
|
|
297
|
+
context.enterSlice(name)
|
|
298
|
+
try {
|
|
299
|
+
return await fn()
|
|
300
|
+
} finally {
|
|
301
|
+
context.leaveSlice?.()
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
287
305
|
// ── Reads of the commit replay stands on ────────────────────────────────────
|
|
288
306
|
|
|
289
307
|
export const read = (path: string): string | undefined => ctx().read(path)
|