@pmelab/gtd 27.1.0 → 27.2.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 +16 -9
- package/dist/chunks/registry-CP3YuV9j.mjs +101 -0
- package/dist/chunks/{reserved-BL9krPao.mjs → reserved-B2QaImnN.mjs} +41 -73
- package/dist/chunks/tail-BnhIxCYv.mjs +81 -0
- package/dist/gtd.bundle.mjs +5 -2
- package/dist/testkit.mjs +5 -2
- package/dist/workflows/cleanup.mjs +37 -0
- package/dist/workflows/feature.mjs +2 -1
- package/dist/workflows/fix.mjs +2 -1
- package/dist/workflows/review.mjs +2 -1
- package/dist/workflows/rollout.mjs +33 -0
- package/dist/workflows.mjs +220 -21
- package/package.json +11 -1
- package/src/flags/index.test.ts +28 -0
- package/src/flags/index.ts +9 -4
- package/src/testkit/steps.ts +26 -0
- package/src/testkit/testkit.feature +8 -1
- package/src/workflows/cleanup/cleanup.feature +323 -0
- package/src/workflows/cleanup/cleanup.test.ts +272 -0
- package/src/workflows/cleanup/cleanup.ts +52 -0
- package/src/workflows/cleanup/index.ts +6 -0
- package/src/workflows/index.ts +3 -1
- package/src/workflows/rollout/index.ts +6 -0
- package/src/workflows/rollout/rollout.feature +214 -0
- package/src/workflows/rollout/rollout.test.ts +167 -0
- package/src/workflows/rollout/rollout.ts +44 -0
- package/src/workflows/shared/health.test.ts +39 -0
- package/src/workflows/shared/health.ts +30 -7
- package/src/workflows/shared/options.test.ts +22 -0
- package/src/workflows/shared/options.ts +19 -0
- package/src/workflows/shared/registry.test.ts +305 -0
- package/src/workflows/shared/registry.ts +158 -0
- package/src/workflows/shared/tail.test.ts +36 -2
- package/src/workflows/shared/tail.ts +30 -18
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Feature: rollout turns one release flag default-on and touches nothing else
|
|
2
|
+
|
|
3
|
+
`gtd --workflow rollout --var flag=<name>` has an agent set `defaultOn: true`
|
|
4
|
+
on the named registry entry; a check refuses any other change, and a red
|
|
5
|
+
suite rests at a human gate — never a fix turn.
|
|
6
|
+
|
|
7
|
+
Background:
|
|
8
|
+
Given a gtd config with:
|
|
9
|
+
"""
|
|
10
|
+
export {
|
|
11
|
+
feature as default,
|
|
12
|
+
feature,
|
|
13
|
+
defaults,
|
|
14
|
+
envDefaults,
|
|
15
|
+
summary,
|
|
16
|
+
steering,
|
|
17
|
+
skills,
|
|
18
|
+
access,
|
|
19
|
+
} from "@pmelab/gtd/workflows/feature"
|
|
20
|
+
import { rollout } from "@pmelab/gtd/workflows"
|
|
21
|
+
export { rollout }
|
|
22
|
+
export const elsewhere = rollout({ flagRegistry: "config/flags.ts" })
|
|
23
|
+
"""
|
|
24
|
+
And a file "src/calc.ts" with:
|
|
25
|
+
"""
|
|
26
|
+
export const add = (a: number, b: number) => a + b
|
|
27
|
+
"""
|
|
28
|
+
And a file "src/flags/index.ts" with:
|
|
29
|
+
"""
|
|
30
|
+
export const FLAGS = [
|
|
31
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
|
|
32
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
|
|
33
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
34
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
35
|
+
] as const
|
|
36
|
+
"""
|
|
37
|
+
And the working tree is committed as "feat: registry"
|
|
38
|
+
|
|
39
|
+
Scenario: a flip of the named entry lands, the suite passes and the process ends
|
|
40
|
+
Given step "rollout.flipping" writes "src/flags/index.ts" with:
|
|
41
|
+
"""
|
|
42
|
+
export const FLAGS = [
|
|
43
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
|
|
44
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
|
|
45
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
46
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
47
|
+
] as const
|
|
48
|
+
"""
|
|
49
|
+
And step "rollout.suite.check" changes nothing
|
|
50
|
+
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
51
|
+
And the process is driven
|
|
52
|
+
Then the process rests at "idle"
|
|
53
|
+
And "src/flags/index.ts" contains "kind: \"release\", defaultOn: true },"
|
|
54
|
+
And the last commit subject is "gtd(check): rollout.suite.check → idle"
|
|
55
|
+
|
|
56
|
+
Scenario: a turn also changing a source file is refused naming that path
|
|
57
|
+
Given step "rollout.flipping" writes "src/flags/index.ts" with:
|
|
58
|
+
"""
|
|
59
|
+
export const FLAGS = [
|
|
60
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
|
|
61
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
|
|
62
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
63
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
64
|
+
] as const
|
|
65
|
+
"""
|
|
66
|
+
And step "rollout.flipping" writes "src/calc.ts" with:
|
|
67
|
+
"""
|
|
68
|
+
export const add = (a: number, b: number) => b + a
|
|
69
|
+
"""
|
|
70
|
+
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
71
|
+
Then driving the process is refused with "rollout-only: src/calc.ts changed"
|
|
72
|
+
|
|
73
|
+
Scenario: a turn editing a second registry entry is refused
|
|
74
|
+
Given step "rollout.flipping" writes "src/flags/index.ts" with:
|
|
75
|
+
"""
|
|
76
|
+
export const FLAGS = [
|
|
77
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
|
|
78
|
+
{ name: "beta", owner: "zed", expiry: "2027-02-01", kind: "release" },
|
|
79
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
80
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
81
|
+
] as const
|
|
82
|
+
"""
|
|
83
|
+
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
84
|
+
Then driving the process is refused with "other edits in src/flags/index.ts"
|
|
85
|
+
|
|
86
|
+
Scenario: a factory flagRegistry names the one file the guard allows
|
|
87
|
+
Given a file "config/flags.ts" with:
|
|
88
|
+
"""
|
|
89
|
+
export const FLAGS = [
|
|
90
|
+
{ name: "gamma", owner: "g", expiry: "2027-05-01", kind: "release" },
|
|
91
|
+
] as const
|
|
92
|
+
"""
|
|
93
|
+
And the working tree is committed as "feat: second registry"
|
|
94
|
+
And step "rollout.flipping" writes "config/flags.ts" with:
|
|
95
|
+
"""
|
|
96
|
+
export const FLAGS = [
|
|
97
|
+
{ name: "gamma", owner: "g", expiry: "2027-05-01", kind: "release", defaultOn: true },
|
|
98
|
+
] as const
|
|
99
|
+
"""
|
|
100
|
+
And step "rollout.suite.check" changes nothing
|
|
101
|
+
When gtd starts workflow "elsewhere" with "--var flag=gamma"
|
|
102
|
+
And the process is driven
|
|
103
|
+
Then the process rests at "idle"
|
|
104
|
+
And "config/flags.ts" contains "defaultOn: true"
|
|
105
|
+
|
|
106
|
+
Scenario: a missing flag var is refused before any turn
|
|
107
|
+
Then starting workflow "rollout" with "" is refused with "gtd: rollout needs --var flag=<name>"
|
|
108
|
+
|
|
109
|
+
Scenario: an unknown flag is refused before any turn
|
|
110
|
+
Then starting workflow "rollout" with "--var flag=nope" is refused with "no flag \"nope\" in src/flags/index.ts"
|
|
111
|
+
|
|
112
|
+
Scenario: an ops flag is refused before any turn
|
|
113
|
+
Then starting workflow "rollout" with "--var flag=ops1" is refused with "flag \"ops1\" is ops, not release"
|
|
114
|
+
|
|
115
|
+
Scenario: an already default-on flag is refused before any turn
|
|
116
|
+
Then starting workflow "rollout" with "--var flag=live" is refused with "flag \"live\" is already default-on"
|
|
117
|
+
|
|
118
|
+
Scenario: a red suite after the flip rests at the blocked gate with no fix turn
|
|
119
|
+
Given step "rollout.flipping" writes "src/flags/index.ts" with:
|
|
120
|
+
"""
|
|
121
|
+
export const FLAGS = [
|
|
122
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
|
|
123
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
|
|
124
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
125
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
126
|
+
] as const
|
|
127
|
+
"""
|
|
128
|
+
And step "rollout.suite.check" writes ".gtd/FEEDBACK.md" with:
|
|
129
|
+
"""
|
|
130
|
+
1 test failing
|
|
131
|
+
"""
|
|
132
|
+
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
133
|
+
And the process is driven
|
|
134
|
+
Then the process rests at "rollout.suite.blocked"
|
|
135
|
+
|
|
136
|
+
@inmem
|
|
137
|
+
Scenario: a human fix at the blocked gate that also edits a source file is refused
|
|
138
|
+
Given step "rollout.flipping" writes "src/flags/index.ts" with:
|
|
139
|
+
"""
|
|
140
|
+
export const FLAGS = [
|
|
141
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
|
|
142
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
|
|
143
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
144
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
145
|
+
] as const
|
|
146
|
+
"""
|
|
147
|
+
And step "rollout.suite.check" writes ".gtd/FEEDBACK.md" with:
|
|
148
|
+
"""
|
|
149
|
+
1 test failing
|
|
150
|
+
"""
|
|
151
|
+
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
152
|
+
And the process is driven
|
|
153
|
+
Then the process rests at "rollout.suite.blocked"
|
|
154
|
+
And step "rollout.suite.blocked" writes "src/calc.ts" with:
|
|
155
|
+
"""
|
|
156
|
+
export const add = (a: number, b: number) => b + a
|
|
157
|
+
"""
|
|
158
|
+
Then driving the process is refused with "rollout-only: src/calc.ts changed"
|
|
159
|
+
|
|
160
|
+
@inmem
|
|
161
|
+
Scenario: a human fix at the blocked gate that edits a second registry entry is refused
|
|
162
|
+
Given step "rollout.flipping" writes "src/flags/index.ts" with:
|
|
163
|
+
"""
|
|
164
|
+
export const FLAGS = [
|
|
165
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
|
|
166
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
|
|
167
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
168
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
169
|
+
] as const
|
|
170
|
+
"""
|
|
171
|
+
And step "rollout.suite.check" writes ".gtd/FEEDBACK.md" with:
|
|
172
|
+
"""
|
|
173
|
+
1 test failing
|
|
174
|
+
"""
|
|
175
|
+
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
176
|
+
And the process is driven
|
|
177
|
+
Then the process rests at "rollout.suite.blocked"
|
|
178
|
+
And step "rollout.suite.blocked" writes "src/flags/index.ts" with:
|
|
179
|
+
"""
|
|
180
|
+
export const FLAGS = [
|
|
181
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
|
|
182
|
+
{ name: "beta", owner: "zed", expiry: "2027-02-01", kind: "release" },
|
|
183
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
184
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
185
|
+
] as const
|
|
186
|
+
"""
|
|
187
|
+
Then driving the process is refused with "other edits in src/flags/index.ts"
|
|
188
|
+
|
|
189
|
+
@inmem
|
|
190
|
+
Scenario: a human fix touching only the feedback file lands and the process ends with no review
|
|
191
|
+
Given step "rollout.flipping" writes "src/flags/index.ts" with:
|
|
192
|
+
"""
|
|
193
|
+
export const FLAGS = [
|
|
194
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
|
|
195
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
|
|
196
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
197
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
198
|
+
] as const
|
|
199
|
+
"""
|
|
200
|
+
And step "rollout.suite.check" writes ".gtd/FEEDBACK.md" with:
|
|
201
|
+
"""
|
|
202
|
+
1 test failing
|
|
203
|
+
"""
|
|
204
|
+
When gtd starts workflow "rollout" with "--var flag=alpha"
|
|
205
|
+
And the process is driven
|
|
206
|
+
Then the process rests at "rollout.suite.blocked"
|
|
207
|
+
And step "rollout.suite.blocked" writes ".gtd/FEEDBACK.md" with:
|
|
208
|
+
"""
|
|
209
|
+
fixed by hand
|
|
210
|
+
"""
|
|
211
|
+
And step "rollout.suite.check" from now on changes nothing
|
|
212
|
+
When the process is driven
|
|
213
|
+
Then the process rests at "idle"
|
|
214
|
+
And the last commit subject is "gtd(check): rollout.suite.check → idle"
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { CONFIG_SCAFFOLD } from "../../install/index.js"
|
|
3
|
+
import { testRepo } from "../../testkit/index.js"
|
|
4
|
+
|
|
5
|
+
const CONFIG = `${CONFIG_SCAFFOLD}export { rollout } from "@pmelab/gtd/workflows"\n`
|
|
6
|
+
|
|
7
|
+
const REGISTRY = `export const FLAGS = [
|
|
8
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
|
|
9
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
|
|
10
|
+
{ name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
11
|
+
{ name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
|
|
12
|
+
] as const
|
|
13
|
+
`
|
|
14
|
+
|
|
15
|
+
const FLIPPED = REGISTRY.replace(
|
|
16
|
+
'{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" }',
|
|
17
|
+
'{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true }',
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
const repoWith = () =>
|
|
21
|
+
testRepo({ workflow: CONFIG, files: { "src/flags/index.ts": REGISTRY, "src/a.ts": "a\n" } })
|
|
22
|
+
|
|
23
|
+
describe("the rollout workflow", () => {
|
|
24
|
+
it("lands a flip, checks the suite and ends with no review", async () => {
|
|
25
|
+
const repo = repoWith()
|
|
26
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
27
|
+
const rest = await repo.drive({
|
|
28
|
+
"rollout.flipping": (beat, r) => {
|
|
29
|
+
expect(beat.content).toContain("alpha")
|
|
30
|
+
r.write("src/flags/index.ts", FLIPPED)
|
|
31
|
+
},
|
|
32
|
+
"rollout.suite.check": () => undefined,
|
|
33
|
+
})
|
|
34
|
+
expect(rest.state).toBe("idle")
|
|
35
|
+
expect(repo.subjects().at(-1)).toBe("gtd(check): rollout.suite.check → idle")
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it("lands a flip when the tree was already dirty at the start", async () => {
|
|
39
|
+
const repo = repoWith()
|
|
40
|
+
repo.write("src/other.ts", "dirty\n")
|
|
41
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
42
|
+
const rest = await repo.drive({
|
|
43
|
+
"rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
|
|
44
|
+
"rollout.suite.check": () => undefined,
|
|
45
|
+
})
|
|
46
|
+
expect(rest.state).toBe("idle")
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("refuses a turn that also changes a source file, naming it", async () => {
|
|
50
|
+
const repo = repoWith()
|
|
51
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
52
|
+
await expect(
|
|
53
|
+
repo.drive({
|
|
54
|
+
"rollout.flipping": (_, r) => {
|
|
55
|
+
r.write("src/flags/index.ts", FLIPPED)
|
|
56
|
+
r.write("src/a.ts", "changed\n")
|
|
57
|
+
},
|
|
58
|
+
}),
|
|
59
|
+
).rejects.toThrow(/rollout-only: src\/a\.ts changed/)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("refuses an edit to a second entry", async () => {
|
|
63
|
+
const repo = repoWith()
|
|
64
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
65
|
+
await expect(
|
|
66
|
+
repo.drive({
|
|
67
|
+
"rollout.flipping": (_, r) =>
|
|
68
|
+
r.write("src/flags/index.ts", FLIPPED.replace('owner: "b"', 'owner: "z"')),
|
|
69
|
+
}),
|
|
70
|
+
).rejects.toThrow("rollout-only: other edits in src/flags/index.ts")
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it("refuses a turn that writes defaultOn: false, naming the fault", async () => {
|
|
74
|
+
const repo = repoWith()
|
|
75
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
76
|
+
await expect(
|
|
77
|
+
repo.drive({
|
|
78
|
+
"rollout.flipping": (_, r) =>
|
|
79
|
+
r.write("src/flags/index.ts", FLIPPED.replace("defaultOn: true }", "defaultOn: false }")),
|
|
80
|
+
}),
|
|
81
|
+
).rejects.toThrow('"alpha" is not set to defaultOn: true')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it("refuses a turn that also writes under .gtd/", async () => {
|
|
85
|
+
const repo = repoWith()
|
|
86
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
87
|
+
await expect(
|
|
88
|
+
repo.drive({
|
|
89
|
+
"rollout.flipping": (_, r) => {
|
|
90
|
+
r.write("src/flags/index.ts", FLIPPED)
|
|
91
|
+
r.write(".gtd/NOTES.md", "planted\n")
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
).rejects.toThrow(/rollout-only: \.gtd\/NOTES\.md changed/)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it.each([
|
|
98
|
+
[[], "gtd: rollout needs --var flag=<name>"],
|
|
99
|
+
[["--var", "flag=nope"], 'gtd: rollout: no flag "nope" in src/flags/index.ts'],
|
|
100
|
+
[["--var", "flag=ops1"], 'gtd: rollout: flag "ops1" is ops, not release'],
|
|
101
|
+
[["--var", "flag=live"], 'gtd: rollout: flag "live" is already default-on'],
|
|
102
|
+
])("refuses %j at start", async (args, message) => {
|
|
103
|
+
await expect(repoWith().start("rollout", ...args)).rejects.toThrow(message)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it("rests at rollout.suite.blocked on a red suite, with no fix turn", async () => {
|
|
107
|
+
const repo = repoWith()
|
|
108
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
109
|
+
const rest = await repo.drive({
|
|
110
|
+
"rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
|
|
111
|
+
"rollout.suite.check": (_, r) => r.write(".gtd/FEEDBACK.md", "1 failing\n"),
|
|
112
|
+
})
|
|
113
|
+
expect(rest).toMatchObject({ state: "rollout.suite.blocked", actor: "human" })
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe("the human's fix at rollout.suite.blocked", () => {
|
|
117
|
+
const blocked = async () => {
|
|
118
|
+
const repo = repoWith()
|
|
119
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
120
|
+
await repo.drive({
|
|
121
|
+
"rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
|
|
122
|
+
"rollout.suite.check": (_, r) => r.write(".gtd/FEEDBACK.md", "1 failing\n"),
|
|
123
|
+
})
|
|
124
|
+
return repo
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
it("refuses a fix that edits a source file, naming it", async () => {
|
|
128
|
+
const repo = await blocked()
|
|
129
|
+
await expect(
|
|
130
|
+
repo.drive({ "rollout.suite.blocked": (_, r) => r.write("src/a.ts", "fixed\n") }),
|
|
131
|
+
).rejects.toThrow(/rollout-only: src\/a\.ts changed/)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("refuses a fix that edits a second registry entry", async () => {
|
|
135
|
+
const repo = await blocked()
|
|
136
|
+
await expect(
|
|
137
|
+
repo.drive({
|
|
138
|
+
"rollout.suite.blocked": (_, r) =>
|
|
139
|
+
r.write("src/flags/index.ts", FLIPPED.replace('owner: "b"', 'owner: "z"')),
|
|
140
|
+
}),
|
|
141
|
+
).rejects.toThrow("rollout-only: other edits in src/flags/index.ts")
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it("refuses a fix that rewrites the flipped entry back to defaultOn: false", async () => {
|
|
145
|
+
const repo = await blocked()
|
|
146
|
+
await expect(
|
|
147
|
+
repo.drive({ "rollout.suite.blocked": (_, r) => r.write("src/flags/index.ts", REGISTRY) }),
|
|
148
|
+
).rejects.toThrow('"alpha" is not set to defaultOn: true')
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it("lands a fix that re-wraps the flag's entry and ends with no review", async () => {
|
|
152
|
+
const repo = await blocked()
|
|
153
|
+
const rest = await repo.drive({
|
|
154
|
+
"rollout.suite.blocked": (_, r) =>
|
|
155
|
+
r.write(
|
|
156
|
+
"src/flags/index.ts",
|
|
157
|
+
FLIPPED.replace(
|
|
158
|
+
'{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true }',
|
|
159
|
+
'{\n name: "alpha",\n owner: "a",\n expiry: "2027-01-01",\n kind: "release",\n defaultOn: true,\n }',
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
"rollout.suite.check": () => undefined,
|
|
163
|
+
})
|
|
164
|
+
expect(rest.state).toBe("idle")
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { agent, changesSince, head, read, refuse, scope, vars } from "../../flows/index.js"
|
|
2
|
+
import { gate } from "../shared/health.js"
|
|
3
|
+
import { flagShipped } from "../shared/options.js"
|
|
4
|
+
import { flipProblems, requireFlag } from "../shared/registry.js"
|
|
5
|
+
import { coder, FEEDBACK } from "../shared/steps.js"
|
|
6
|
+
|
|
7
|
+
const flippingPrompt = (name: string, registry: string): string =>
|
|
8
|
+
`Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`
|
|
9
|
+
|
|
10
|
+
const suiteBlockedMessage = (): string =>
|
|
11
|
+
"The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land."
|
|
12
|
+
|
|
13
|
+
// No review gate — a one-field change needs no review lap.
|
|
14
|
+
export const rolloutFlow = async (): Promise<void> => {
|
|
15
|
+
const registry = vars.flagRegistry ?? ""
|
|
16
|
+
const name = vars.flag ?? ""
|
|
17
|
+
// Before the turn, never after: replay re-runs every pre-turn check.
|
|
18
|
+
requireFlag("rollout", registry, name, false)
|
|
19
|
+
// Strays count from the same point `before` is read at, not `start()`: the opening commit sweeps in whatever the human left uncommitted.
|
|
20
|
+
const base = head()
|
|
21
|
+
const before = read(registry) ?? ""
|
|
22
|
+
const guard = (): void => {
|
|
23
|
+
const strays = changesSince(base).paths.filter((path) => path !== registry && path !== FEEDBACK)
|
|
24
|
+
if (strays.length > 0) {
|
|
25
|
+
refuse(
|
|
26
|
+
`gtd land: rollout-only: ${strays.join(", ")} changed — rollout may only set defaultOn: true on "${name}" in ${registry}`,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
const problems = flipProblems(before, read(registry) ?? "", name)
|
|
30
|
+
if (problems.length > 0) {
|
|
31
|
+
refuse(`gtd land: rollout-only: other edits in ${registry}: ${problems.join("; ")}`)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
await scope("rollout", async () => {
|
|
35
|
+
await agent("flipping", flippingPrompt(name, registry), {
|
|
36
|
+
label: `Rolling out ${name}`,
|
|
37
|
+
model: coder(),
|
|
38
|
+
})
|
|
39
|
+
guard()
|
|
40
|
+
await gate("suite", suiteBlockedMessage(), guard)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const rollout = flagShipped(rolloutFlow)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { toRequest } from "../../judges/index.js"
|
|
3
|
+
import { testRepo } from "../../testkit/index.js"
|
|
3
4
|
import { retryQuestion } from "./health.js"
|
|
4
5
|
|
|
5
6
|
describe("retryQuestion", () => {
|
|
@@ -11,3 +12,41 @@ describe("retryQuestion", () => {
|
|
|
11
12
|
).toBeGreaterThanOrEqual(2)
|
|
12
13
|
})
|
|
13
14
|
})
|
|
15
|
+
|
|
16
|
+
describe("gate guard", () => {
|
|
17
|
+
const config = (guard: string) =>
|
|
18
|
+
[
|
|
19
|
+
`import { gate } from "@pmelab/gtd/workflows"`,
|
|
20
|
+
`import { agent, refuse } from "@pmelab/gtd/flows"`,
|
|
21
|
+
`export const held = async () => { await agent("first", "go"); ${guard}; await gate("suite", "fix it", guard) }`,
|
|
22
|
+
`export default held`,
|
|
23
|
+
].join("\n")
|
|
24
|
+
|
|
25
|
+
const red = {
|
|
26
|
+
first: (_: unknown, r: { write(p: string, c: string): void }) => r.write("f", "1\n"),
|
|
27
|
+
"suite.check": (_: unknown, r: { write(p: string, c: string): void }) =>
|
|
28
|
+
r.write(".gtd/FEEDBACK.md", "red\n"),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
it("runs the guard after a blocked landing and lets its refusal stop it", async () => {
|
|
32
|
+
const repo = testRepo({
|
|
33
|
+
workflow: config('const guard = () => { refuse("guarded: nope") }'),
|
|
34
|
+
env: { GTD_TEST_COMMAND: "true" },
|
|
35
|
+
})
|
|
36
|
+
await repo.start("held")
|
|
37
|
+
await repo.drive(red)
|
|
38
|
+
await expect(repo.drive({ "suite.blocked": (_, r) => r.write("x", "1\n") })).rejects.toThrow(
|
|
39
|
+
"guarded: nope",
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it("never runs the guard after the check step", async () => {
|
|
44
|
+
const repo = testRepo({
|
|
45
|
+
workflow: config('const guard = () => { refuse("guarded: nope") }'),
|
|
46
|
+
env: { GTD_TEST_COMMAND: "true" },
|
|
47
|
+
})
|
|
48
|
+
await repo.start("held")
|
|
49
|
+
const rest = await repo.drive(red)
|
|
50
|
+
expect(rest.state).toBe("suite.blocked")
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -28,13 +28,30 @@ const escalationSteps: kit.EscalationSteps = {
|
|
|
28
28
|
exhausted: escalationExhausted,
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/** Runs after an agent or human turn; refuses it by throwing (`refuse`). */
|
|
32
|
+
export type Guard = () => void
|
|
33
|
+
|
|
34
|
+
export const guarded =
|
|
35
|
+
<A extends unknown[]>(turn: (...a: A) => Promise<void>, guard?: Guard) =>
|
|
36
|
+
async (...a: A): Promise<void> => {
|
|
37
|
+
await turn(...a)
|
|
38
|
+
guard?.()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const guardedSteps = (guard?: Guard): kit.EscalationSteps => ({
|
|
42
|
+
limit: escalationSteps.limit,
|
|
43
|
+
describe: guarded(escalationSteps.describe, guard),
|
|
44
|
+
stop: guarded(escalationSteps.stop, guard),
|
|
45
|
+
exhausted: guarded(escalationSteps.exhausted, guard),
|
|
46
|
+
})
|
|
47
|
+
|
|
31
48
|
/**
|
|
32
49
|
* Hand a red suite that repeated attempts could not fix to a person: an
|
|
33
50
|
* agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
|
|
34
51
|
* on, the human is told the budget is spent instead.
|
|
35
52
|
*/
|
|
36
|
-
export const escalation = (count: kit.EscalationCount): Promise<void> =>
|
|
37
|
-
kit.escalation(count,
|
|
53
|
+
export const escalation = (count: kit.EscalationCount, guard?: Guard): Promise<void> =>
|
|
54
|
+
kit.escalation(count, guardedSteps(guard))
|
|
38
55
|
|
|
39
56
|
/** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
|
|
40
57
|
export const retryQuestion = (comparable: boolean): JudgeQuestion =>
|
|
@@ -79,6 +96,8 @@ export interface HealthOptions {
|
|
|
79
96
|
readonly escalations?: kit.EscalationCount
|
|
80
97
|
/** Paths removed on a green run. */
|
|
81
98
|
readonly sweepOnGreen?: readonly string[]
|
|
99
|
+
/** Runs after every fix and escalation turn. */
|
|
100
|
+
readonly guard?: Guard | undefined
|
|
82
101
|
}
|
|
83
102
|
|
|
84
103
|
const runSuite = (options: HealthOptions): Promise<boolean> =>
|
|
@@ -95,20 +114,24 @@ const runSuite = (options: HealthOptions): Promise<boolean> =>
|
|
|
95
114
|
* identical to the previous round's.
|
|
96
115
|
*/
|
|
97
116
|
export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
|
|
98
|
-
kit.healthy(fix, {
|
|
117
|
+
kit.healthy(guarded(fix, options.guard), {
|
|
99
118
|
check: () => runSuite(options),
|
|
100
119
|
report: FEEDBACK,
|
|
101
120
|
fixCap: FIX_CAP,
|
|
102
121
|
fixesSoFar: options.fixesSoFar ?? 0,
|
|
103
122
|
sameFailure,
|
|
104
123
|
escalations: options.escalations ?? { rounds: 0 },
|
|
105
|
-
escalation:
|
|
124
|
+
escalation: guardedSteps(options.guard),
|
|
106
125
|
})
|
|
107
126
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Hold the process at `<name>.blocked` until the suite is green. `guard` runs
|
|
129
|
+
* after each `blocked` landing only — never after a check, whose refusal would
|
|
130
|
+
* re-run the same script forever.
|
|
131
|
+
*/
|
|
132
|
+
export const gate = (name: string, message: string, guard?: Guard): Promise<void> =>
|
|
110
133
|
kit.gate(
|
|
111
134
|
name,
|
|
112
135
|
() => baseline("check"),
|
|
113
|
-
() => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }),
|
|
136
|
+
guarded(() => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }), guard),
|
|
114
137
|
)
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { configure } from "../../flows/index.js"
|
|
3
3
|
import * as workflows from "../index.js"
|
|
4
|
+
import * as options from "./options.js"
|
|
4
5
|
|
|
5
6
|
const { defaults, envDefaults, feature, fix, review, ShippedOptions } = workflows
|
|
7
|
+
const { flagShipped } = options
|
|
6
8
|
|
|
7
9
|
describe("the shipped workflow factories", () => {
|
|
8
10
|
it("take exactly the settings the shipped workflows declare as options", () => {
|
|
@@ -57,4 +59,24 @@ describe("the shipped workflow factories", () => {
|
|
|
57
59
|
const result = configure(fix({ judgeBudgetBytes: 65536 }))
|
|
58
60
|
expect(result).toMatchObject({ ok: false, issues: [{ path: ["judgeBudgetBytes"] }] })
|
|
59
61
|
})
|
|
62
|
+
|
|
63
|
+
describe("flagShipped", () => {
|
|
64
|
+
const factory = flagShipped(async () => {})
|
|
65
|
+
|
|
66
|
+
it("declare flag and flagRegistry as process settings, so --var flag= is accepted", () => {
|
|
67
|
+
expect(configure(factory)).toMatchObject({
|
|
68
|
+
ok: true,
|
|
69
|
+
build: {
|
|
70
|
+
defaults: { ...defaults, flag: "", flagRegistry: "src/flags/index.ts" },
|
|
71
|
+
envDefaults,
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("fail the load on an unknown option", () => {
|
|
77
|
+
// @ts-expect-error — `flag` is a --var, not an option
|
|
78
|
+
const result = configure(factory({ flag: "x" }))
|
|
79
|
+
expect(result).toMatchObject({ ok: false, issues: [{ path: ["flag"] }] })
|
|
80
|
+
})
|
|
81
|
+
})
|
|
60
82
|
})
|
|
@@ -48,3 +48,22 @@ export const shipped = (
|
|
|
48
48
|
base,
|
|
49
49
|
}),
|
|
50
50
|
)
|
|
51
|
+
|
|
52
|
+
export const FlagOptions = Schema.Struct({ ...ShippedOptions.fields, flagRegistry: setting })
|
|
53
|
+
|
|
54
|
+
export type FlagOptions = Schema.Schema.Encoded<typeof FlagOptions>
|
|
55
|
+
|
|
56
|
+
export const flagShipped = (flow: Flow): WorkflowFactory<FlagOptions> =>
|
|
57
|
+
defineWorkflow(
|
|
58
|
+
Schema.standardSchemaV1(FlagOptions, { onExcessProperty: "error", errors: "all" }),
|
|
59
|
+
(options) => {
|
|
60
|
+
const flagDefaults = { ...defaults, flag: "", flagRegistry: "src/flags/index.ts" }
|
|
61
|
+
return {
|
|
62
|
+
flow,
|
|
63
|
+
defaults: over(flagDefaults, options),
|
|
64
|
+
envDefaults: over(envDefaults, options),
|
|
65
|
+
doors: {},
|
|
66
|
+
base: undefined,
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
)
|