@erclx/canon 5.69.0 → 5.71.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/CHANGELOG.md +25 -0
- package/README.md +33 -159
- package/assets/brand/mark.svg +17 -9
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/app-drive/SKILL.md +3 -2
- package/claude/skills/app-drive/references/drive-record.md +52 -10
- package/claude/skills/app-drive/references/isolate.md +1 -1
- package/claude/skills/design-pick/SKILL.md +1 -0
- package/claude/skills/git-pr/references/task-number.md +13 -0
- package/claude/skills/markdown-craft/references/markdown.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +1 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -20
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +10 -0
- package/claude/skills/role-orchestrator/references/orchestrator-talk-launch.md +30 -0
- package/claude/skills/task-board/references/tasks-record-lines.md +40 -0
- package/claude/skills/task-board/references/tasks.md +4 -8
- package/claude/skills/ui-craft/references/copy.md +4 -1
- package/claude/skills/ui-film/REQUIREMENT.md +4 -2
- package/claude/skills/ui-film/SKILL.md +24 -11
- package/claude/skills/ui-film/references/compose-craft.md +81 -0
- package/claude/skills/ui-film/references/config.md +6 -1
- package/claude/skills/ui-film/references/handoff.md +13 -3
- package/claude/skills/ui-film/references/take-craft.md +27 -1
- package/docs/index.md +1 -0
- package/docs/migrations/by-hand.md +107 -0
- package/docs/migrations/by-verb.md +118 -0
- package/docs/migrations/index.md +14 -0
- package/docs/target/index.md +1 -2
- package/docs/target/projects.md +2 -2
- package/docs/target/sync.md +22 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/ledger/skills/ui-craft/copy.md +8 -4
- package/package.json +3 -1
- package/scripts/core/design-sync.ts +204 -0
- package/scripts/core/regen-web-favicon.ts +27 -12
- package/scripts/core/regen-web-previews.ts +8 -1
- package/scripts/core/render-film.ts +179 -0
- package/src/capture/frame-values.ts +12 -12
- package/src/commands/markdown.ts +6 -1
- package/src/commands/records.ts +3 -2
- package/src/commands/sandbox.ts +9 -2
- package/src/commands/take.ts +159 -11
- package/src/commands/tasks.ts +133 -0
- package/src/design/favicon.ts +85 -13
- package/src/design/reference.ts +98 -0
- package/src/design/render.ts +16 -14
- package/src/design/tokens.ts +4 -3
- package/src/gate/stages.ts +6 -0
- package/src/init/steps.ts +2 -2
- package/src/markdown/colons.ts +102 -16
- package/src/migrate/status.ts +3 -3
- package/src/records/validate.ts +14 -2
- package/src/sandbox/expect.ts +32 -0
- package/src/sandbox/headless/run.ts +6 -3
- package/src/take/camera.ts +99 -10
- package/src/take/film.ts +35 -2
- package/src/take/fixtures/input.html +138 -0
- package/src/take/keys.ts +193 -0
- package/src/take/log.ts +94 -1
- package/src/take/perform.ts +165 -10
- package/src/take/plan.ts +101 -6
- package/src/take/screencast.ts +28 -3
- package/src/take/stepper.ts +122 -18
- package/src/tasks/archive.ts +54 -1
- package/src/tasks/record.ts +118 -0
- package/src/tasks/validate.ts +37 -0
- package/docs/target/migrations.md +0 -205
package/src/take/plan.ts
CHANGED
|
@@ -6,19 +6,44 @@
|
|
|
6
6
|
* keyboard leaves nothing in the log to draw.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { MODIFIERS, type Modifier, isModifier, parseKeys } from '@/take/keys'
|
|
10
|
+
|
|
9
11
|
/** How a beat is filmed: frame stepping, or Playwright's real-time screencast. */
|
|
10
12
|
export const ROUTES = ['step', 'screencast'] as const
|
|
11
13
|
|
|
12
14
|
export type Route = (typeof ROUTES)[number]
|
|
13
15
|
|
|
16
|
+
/** The keys held through a press, absent when none is. */
|
|
17
|
+
interface Held {
|
|
18
|
+
readonly modifiers?: readonly Modifier[]
|
|
19
|
+
}
|
|
20
|
+
|
|
14
21
|
export type TakeStep =
|
|
15
22
|
| { readonly kind: 'move'; readonly target: string }
|
|
16
23
|
| { readonly kind: 'move'; readonly x: number; readonly y: number }
|
|
17
|
-
| { readonly kind: 'click'; readonly target: string }
|
|
24
|
+
| ({ readonly kind: 'click'; readonly target: string } & Held)
|
|
25
|
+
| ({ readonly kind: 'dblclick'; readonly target: string } & Held)
|
|
26
|
+
| ({
|
|
27
|
+
readonly kind: 'drag'
|
|
28
|
+
readonly from: string
|
|
29
|
+
readonly to: string
|
|
30
|
+
} & Held)
|
|
31
|
+
| { readonly kind: 'key'; readonly keys: string }
|
|
18
32
|
| { readonly kind: 'fill'; readonly target: string; readonly text: string }
|
|
19
33
|
| { readonly kind: 'scroll'; readonly target: string; readonly dy: number }
|
|
20
34
|
| { readonly kind: 'wait'; readonly ms: number }
|
|
21
35
|
|
|
36
|
+
const KINDS = [
|
|
37
|
+
'move',
|
|
38
|
+
'click',
|
|
39
|
+
'dblclick',
|
|
40
|
+
'drag',
|
|
41
|
+
'key',
|
|
42
|
+
'fill',
|
|
43
|
+
'scroll',
|
|
44
|
+
'wait',
|
|
45
|
+
] as const
|
|
46
|
+
|
|
22
47
|
export interface Beat {
|
|
23
48
|
readonly name: string
|
|
24
49
|
/**
|
|
@@ -26,6 +51,11 @@ export interface Beat {
|
|
|
26
51
|
* back to `screencast` on its own, and the take's record says which ran.
|
|
27
52
|
*/
|
|
28
53
|
readonly route: Route
|
|
54
|
+
/**
|
|
55
|
+
* False turns the camera's zoom off for this beat, while its track still
|
|
56
|
+
* carries the cursor and the click ring. Absent means the zoom runs.
|
|
57
|
+
*/
|
|
58
|
+
readonly push?: false
|
|
29
59
|
/** Runs after the page loads and before the first frame is kept. */
|
|
30
60
|
readonly setup: readonly TakeStep[]
|
|
31
61
|
readonly steps: readonly TakeStep[]
|
|
@@ -89,16 +119,25 @@ function readPlan(document: unknown): TakePlan {
|
|
|
89
119
|
throw new Refusal('unreadable-plan', 'the document is not an object')
|
|
90
120
|
}
|
|
91
121
|
const { beats } = document
|
|
122
|
+
const route = document.route ?? 'step'
|
|
123
|
+
if (!ROUTES.includes(route as Route)) {
|
|
124
|
+
throw new Refusal(
|
|
125
|
+
'unknown-route',
|
|
126
|
+
`the plan names route ${String(route)}. This build records on: ${ROUTES.join(', ')}.`,
|
|
127
|
+
)
|
|
128
|
+
}
|
|
92
129
|
if (!Array.isArray(beats) || beats.length === 0) {
|
|
93
130
|
throw new Refusal(
|
|
94
131
|
'no-beats',
|
|
95
132
|
'beats carries no entry, so nothing is filmed',
|
|
96
133
|
)
|
|
97
134
|
}
|
|
98
|
-
return {
|
|
135
|
+
return {
|
|
136
|
+
beats: beats.map((entry, index) => readBeat(entry, index, route as Route)),
|
|
137
|
+
}
|
|
99
138
|
}
|
|
100
139
|
|
|
101
|
-
function readBeat(entry: unknown, index: number): Beat {
|
|
140
|
+
function readBeat(entry: unknown, index: number, fallback: Route): Beat {
|
|
102
141
|
const at = `beat ${index + 1}`
|
|
103
142
|
if (!isRecord(entry)) throw new Refusal('bad-beat', `${at} is not an object`)
|
|
104
143
|
|
|
@@ -110,7 +149,7 @@ function readBeat(entry: unknown, index: number): Beat {
|
|
|
110
149
|
)
|
|
111
150
|
}
|
|
112
151
|
|
|
113
|
-
const route = entry.route ??
|
|
152
|
+
const route = entry.route ?? fallback
|
|
114
153
|
if (!ROUTES.includes(route as Route)) {
|
|
115
154
|
throw new Refusal(
|
|
116
155
|
'unknown-route',
|
|
@@ -127,10 +166,17 @@ function readBeat(entry: unknown, index: number): Beat {
|
|
|
127
166
|
if (entry.setup !== undefined && !Array.isArray(entry.setup)) {
|
|
128
167
|
throw new Refusal('bad-beat', `${name} carries a setup that is not a list`)
|
|
129
168
|
}
|
|
169
|
+
if (entry.push !== undefined && typeof entry.push !== 'boolean') {
|
|
170
|
+
throw new Refusal(
|
|
171
|
+
'bad-beat',
|
|
172
|
+
`${name} carries a push that is not true or false`,
|
|
173
|
+
)
|
|
174
|
+
}
|
|
130
175
|
|
|
131
176
|
return {
|
|
132
177
|
name,
|
|
133
178
|
route: route as Route,
|
|
179
|
+
...(entry.push === false ? { push: false as const } : {}),
|
|
134
180
|
setup: (entry.setup ?? []).map((step: unknown, i: number) =>
|
|
135
181
|
readStep(step, `${name}, setup step ${i + 1}`),
|
|
136
182
|
),
|
|
@@ -160,7 +206,35 @@ function readStep(entry: unknown, at: string): TakeStep {
|
|
|
160
206
|
`${at} is a move with neither a target nor an x and y`,
|
|
161
207
|
)
|
|
162
208
|
case 'click':
|
|
163
|
-
|
|
209
|
+
case 'dblclick':
|
|
210
|
+
return {
|
|
211
|
+
kind: entry.kind,
|
|
212
|
+
target: needTarget(),
|
|
213
|
+
...readModifiers(entry.modifiers, at),
|
|
214
|
+
}
|
|
215
|
+
case 'drag':
|
|
216
|
+
if (typeof entry.from !== 'string' || !entry.from) {
|
|
217
|
+
throw new Refusal('bad-step', `${at} is a drag with no from target`)
|
|
218
|
+
}
|
|
219
|
+
if (typeof entry.to !== 'string' || !entry.to) {
|
|
220
|
+
throw new Refusal('bad-step', `${at} is a drag with no to target`)
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
kind: 'drag',
|
|
224
|
+
from: entry.from,
|
|
225
|
+
to: entry.to,
|
|
226
|
+
...readModifiers(entry.modifiers, at),
|
|
227
|
+
}
|
|
228
|
+
case 'key': {
|
|
229
|
+
if (typeof entry.keys !== 'string') {
|
|
230
|
+
throw new Refusal('bad-step', `${at} is a key with no keys to press`)
|
|
231
|
+
}
|
|
232
|
+
const read = parseKeys(entry.keys)
|
|
233
|
+
if (read.kind === 'refused') {
|
|
234
|
+
throw new Refusal('bad-step', `${at} is a key where ${read.detail}`)
|
|
235
|
+
}
|
|
236
|
+
return { kind: 'key', keys: entry.keys }
|
|
237
|
+
}
|
|
164
238
|
case 'fill':
|
|
165
239
|
if (typeof entry.text !== 'string') {
|
|
166
240
|
throw new Refusal('bad-step', `${at} is a fill with no text`)
|
|
@@ -179,9 +253,30 @@ function readStep(entry: unknown, at: string): TakeStep {
|
|
|
179
253
|
default:
|
|
180
254
|
throw new Refusal(
|
|
181
255
|
'bad-step',
|
|
182
|
-
`${at} names kind ${String(entry.kind)}, which is not
|
|
256
|
+
`${at} names kind ${String(entry.kind)}, which is not ${KINDS.join(', ')}`,
|
|
257
|
+
)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function readModifiers(value: unknown, at: string): Held {
|
|
262
|
+
if (value === undefined) return {}
|
|
263
|
+
if (!Array.isArray(value)) {
|
|
264
|
+
throw new Refusal('bad-step', `${at} carries modifiers that are not a list`)
|
|
265
|
+
}
|
|
266
|
+
const modifiers: Modifier[] = []
|
|
267
|
+
for (const name of value) {
|
|
268
|
+
if (!isModifier(name)) {
|
|
269
|
+
throw new Refusal(
|
|
270
|
+
'bad-step',
|
|
271
|
+
`${at} holds ${String(name)}, which is not ${MODIFIERS.join(', ')}`,
|
|
183
272
|
)
|
|
273
|
+
}
|
|
274
|
+
if (modifiers.includes(name)) {
|
|
275
|
+
throw new Refusal('bad-step', `${at} holds ${name} twice`)
|
|
276
|
+
}
|
|
277
|
+
modifiers.push(name)
|
|
184
278
|
}
|
|
279
|
+
return modifiers.length > 0 ? { modifiers } : {}
|
|
185
280
|
}
|
|
186
281
|
|
|
187
282
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
package/src/take/screencast.ts
CHANGED
|
@@ -4,7 +4,12 @@ import { join } from 'node:path'
|
|
|
4
4
|
import { chromium } from 'playwright-core'
|
|
5
5
|
import { lastFrame, probeFrames, transcode } from '@/take/encode'
|
|
6
6
|
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
type PointerInput,
|
|
9
|
+
type Stage,
|
|
10
|
+
TargetMissing,
|
|
11
|
+
performSteps,
|
|
12
|
+
} from '@/take/perform'
|
|
8
13
|
import type { Beat } from '@/take/plan'
|
|
9
14
|
import { HOLD_MS } from '@/take/stepper'
|
|
10
15
|
|
|
@@ -19,6 +24,9 @@ const FRAME_MS = 1000 / FPS
|
|
|
19
24
|
|
|
20
25
|
const SETTLE_MS = 2500
|
|
21
26
|
|
|
27
|
+
/** How long a refusal waits on the screenshot it shows. */
|
|
28
|
+
const STILL_CAP_MS = 2000
|
|
29
|
+
|
|
22
30
|
export interface ScreencastTake {
|
|
23
31
|
readonly frames: number
|
|
24
32
|
readonly events: readonly LogEvent[]
|
|
@@ -33,8 +41,15 @@ export async function filmScreencast(request: {
|
|
|
33
41
|
}): Promise<ScreencastTake> {
|
|
34
42
|
const scratch = mkdtempSync(join(tmpdir(), 'canon-take-cast-'))
|
|
35
43
|
const browser = await chromium.launch()
|
|
44
|
+
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
36
45
|
try {
|
|
37
46
|
const page = await browser.newPage({ viewport: VIEWPORT })
|
|
47
|
+
// Best effort, so a closed or crashed page leaves the refusal standing
|
|
48
|
+
// without a still rather than replacing it.
|
|
49
|
+
still = () =>
|
|
50
|
+
page
|
|
51
|
+
.screenshot({ type: 'jpeg', quality: 95, timeout: STILL_CAP_MS })
|
|
52
|
+
.catch(() => undefined)
|
|
38
53
|
await page.goto(request.url, { waitUntil: 'load' })
|
|
39
54
|
await page.waitForTimeout(SETTLE_MS)
|
|
40
55
|
|
|
@@ -51,6 +66,13 @@ export async function filmScreencast(request: {
|
|
|
51
66
|
await page.keyboard.insertText(text)
|
|
52
67
|
await page.waitForTimeout(FRAME_MS)
|
|
53
68
|
},
|
|
69
|
+
// Playwright's mouse reads the modifiers its keyboard holds, so a press
|
|
70
|
+
// here is all a modified click or drag needs on this route.
|
|
71
|
+
press: async (input) => {
|
|
72
|
+
if (input.type === 'down') await page.keyboard.down(input.key)
|
|
73
|
+
else await page.keyboard.up(input.key)
|
|
74
|
+
await page.waitForTimeout(FRAME_MS)
|
|
75
|
+
},
|
|
54
76
|
evaluate: (expression) => page.evaluate(expression),
|
|
55
77
|
})
|
|
56
78
|
|
|
@@ -75,6 +97,9 @@ export async function filmScreencast(request: {
|
|
|
75
97
|
await transcode(webm, request.clip, FPS)
|
|
76
98
|
const frames = await probeFrames(request.clip)
|
|
77
99
|
return { frames, events, still: await lastFrame(request.clip, frames) }
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error instanceof TargetMissing && still) error.still = await still()
|
|
102
|
+
throw error
|
|
78
103
|
} finally {
|
|
79
104
|
await browser.close()
|
|
80
105
|
rmSync(scratch, { recursive: true, force: true })
|
|
@@ -88,9 +113,9 @@ async function send(mouse: Mouse, input: PointerInput): Promise<void> {
|
|
|
88
113
|
case 'move':
|
|
89
114
|
return mouse.move(input.x, input.y)
|
|
90
115
|
case 'down':
|
|
91
|
-
return mouse.down()
|
|
116
|
+
return mouse.down({ clickCount: input.clickCount })
|
|
92
117
|
case 'up':
|
|
93
|
-
return mouse.up()
|
|
118
|
+
return mouse.up({ clickCount: input.clickCount })
|
|
94
119
|
case 'wheel':
|
|
95
120
|
return mouse.wheel(0, input.dy)
|
|
96
121
|
}
|
package/src/take/stepper.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { openFrameEncoder } from '@/take/encode'
|
|
2
|
+
import { describeKey, modifierBits } from '@/take/keys'
|
|
2
3
|
import { FPS, type LogEvent, START, VIEWPORT } from '@/take/log'
|
|
3
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
type KeyInput,
|
|
6
|
+
type PointerInput,
|
|
7
|
+
type Stage,
|
|
8
|
+
TargetMissing,
|
|
9
|
+
performSteps,
|
|
10
|
+
} from '@/take/perform'
|
|
4
11
|
import type { Beat } from '@/take/plan'
|
|
5
12
|
import { type ShellPage, openShell } from '@/take/shell'
|
|
6
13
|
|
|
@@ -36,6 +43,9 @@ const LOAD_CAP_MS = 15_000
|
|
|
36
43
|
const SETTLE_MS = 2500
|
|
37
44
|
const LOADING_FRAME_CAP_MS = 250
|
|
38
45
|
|
|
46
|
+
/** How long a refusal waits on the frame it shows, since the page may be wedged. */
|
|
47
|
+
const STILL_CAP_MS = 2000
|
|
48
|
+
|
|
39
49
|
/** A frame that took longer than `STALL_MS`, so the beat is filmed another way. */
|
|
40
50
|
export class StepperStalled extends Error {
|
|
41
51
|
constructor(
|
|
@@ -68,6 +78,7 @@ export async function filmStepped(
|
|
|
68
78
|
const page = await openShell(request.executable, VIEWPORT)
|
|
69
79
|
const encoder = openFrameEncoder(request.clip, FPS)
|
|
70
80
|
const stallMs = request.stallMs ?? STALL_MS
|
|
81
|
+
let still: (() => Promise<Buffer | undefined>) | undefined
|
|
71
82
|
try {
|
|
72
83
|
let ticks = await load(page, request.url)
|
|
73
84
|
const beginFrame = (screenshot: boolean) =>
|
|
@@ -81,6 +92,7 @@ export async function filmStepped(
|
|
|
81
92
|
: {}),
|
|
82
93
|
},
|
|
83
94
|
)
|
|
95
|
+
still = () => frameStill(beginFrame)
|
|
84
96
|
await pump(beginFrame, SETTLE_MS)
|
|
85
97
|
|
|
86
98
|
const live = liveStage(page, beginFrame, stallMs)
|
|
@@ -128,16 +140,14 @@ export async function filmStepped(
|
|
|
128
140
|
hold: async (ms) => {
|
|
129
141
|
for (let i = 0; i < Math.round(ms / FRAME_MS); i += 1) await frame()
|
|
130
142
|
},
|
|
131
|
-
pointer:
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
key:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
await sent
|
|
140
|
-
},
|
|
143
|
+
pointer: (input) =>
|
|
144
|
+
stepThen(
|
|
145
|
+
page.send('Input.dispatchMouseEvent', mouseEvent(input)),
|
|
146
|
+
frame,
|
|
147
|
+
),
|
|
148
|
+
key: (text) => stepThen(page.send('Input.insertText', { text }), frame),
|
|
149
|
+
press: (input) =>
|
|
150
|
+
stepThen(page.send('Input.dispatchKeyEvent', keyEvent(input)), frame),
|
|
141
151
|
evaluate: (expression) => page.evaluate(expression),
|
|
142
152
|
}
|
|
143
153
|
|
|
@@ -153,12 +163,31 @@ export async function filmStepped(
|
|
|
153
163
|
return { frames, events, still: last }
|
|
154
164
|
} catch (error) {
|
|
155
165
|
encoder.abort()
|
|
166
|
+
if (error instanceof TargetMissing && still) error.still = await still()
|
|
156
167
|
throw error
|
|
157
168
|
} finally {
|
|
158
169
|
page.close()
|
|
159
170
|
}
|
|
160
171
|
}
|
|
161
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Begins one frame with a screenshot for a refusal to show, through the same
|
|
175
|
+
* begin-frame call every kept frame uses, since a separate capture call can
|
|
176
|
+
* wait on a frame that never begins. Best effort, so a page that answers with
|
|
177
|
+
* nothing leaves the refusal standing without a still.
|
|
178
|
+
*/
|
|
179
|
+
async function frameStill(
|
|
180
|
+
beginFrame: (screenshot: boolean) => Promise<{ screenshotData?: string }>,
|
|
181
|
+
): Promise<Buffer | undefined> {
|
|
182
|
+
const reply = await Promise.race([
|
|
183
|
+
beginFrame(true).catch(() => undefined),
|
|
184
|
+
sleep(STILL_CAP_MS),
|
|
185
|
+
])
|
|
186
|
+
return reply?.screenshotData
|
|
187
|
+
? Buffer.from(reply.screenshotData, 'base64')
|
|
188
|
+
: undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
162
191
|
/**
|
|
163
192
|
* Navigates and resolves with Chromium's monotonic clock in milliseconds, read
|
|
164
193
|
* off the load event's own timestamp. The runtime's clock cannot stand in for
|
|
@@ -217,7 +246,7 @@ function liveStage(
|
|
|
217
246
|
pointer: (input) => {
|
|
218
247
|
const sent = page.send('Input.dispatchMouseEvent', mouseEvent(input))
|
|
219
248
|
return withinStall(
|
|
220
|
-
beginFrame(false)
|
|
249
|
+
Promise.all([beginFrame(false), sent]),
|
|
221
250
|
0,
|
|
222
251
|
stallMs,
|
|
223
252
|
).then(() => {})
|
|
@@ -225,7 +254,15 @@ function liveStage(
|
|
|
225
254
|
key: (text) => {
|
|
226
255
|
const sent = page.send('Input.insertText', { text })
|
|
227
256
|
return withinStall(
|
|
228
|
-
beginFrame(false)
|
|
257
|
+
Promise.all([beginFrame(false), sent]),
|
|
258
|
+
0,
|
|
259
|
+
stallMs,
|
|
260
|
+
).then(() => {})
|
|
261
|
+
},
|
|
262
|
+
press: (input) => {
|
|
263
|
+
const sent = page.send('Input.dispatchKeyEvent', keyEvent(input))
|
|
264
|
+
return withinStall(
|
|
265
|
+
Promise.all([beginFrame(false), sent]),
|
|
229
266
|
0,
|
|
230
267
|
stallMs,
|
|
231
268
|
).then(() => {})
|
|
@@ -234,18 +271,83 @@ function liveStage(
|
|
|
234
271
|
}
|
|
235
272
|
}
|
|
236
273
|
|
|
274
|
+
/**
|
|
275
|
+
* A pointer input as CDP takes it. `buttons` carries the held button through a
|
|
276
|
+
* drag's moves, or the page reads them as a hover, and `modifiers` carries the
|
|
277
|
+
* held keys as a bit field beside the key events that pressed them.
|
|
278
|
+
*/
|
|
237
279
|
function mouseEvent(input: PointerInput): Record<string, unknown> {
|
|
238
280
|
const at = { x: input.x, y: input.y }
|
|
281
|
+
if (input.type === 'wheel') {
|
|
282
|
+
return { type: 'mouseWheel', ...at, deltaX: 0, deltaY: input.dy }
|
|
283
|
+
}
|
|
284
|
+
const modifiers = modifierBits(input.modifiers ?? [])
|
|
239
285
|
switch (input.type) {
|
|
240
286
|
case 'move':
|
|
241
|
-
return
|
|
287
|
+
return input.held
|
|
288
|
+
? { type: 'mouseMoved', ...at, button: 'left', buttons: 1, modifiers }
|
|
289
|
+
: { type: 'mouseMoved', ...at, button: 'none', buttons: 0, modifiers }
|
|
242
290
|
case 'down':
|
|
243
|
-
return { type: 'mousePressed', ...at, button: 'left', clickCount: 1 }
|
|
244
291
|
case 'up':
|
|
245
|
-
return {
|
|
246
|
-
|
|
247
|
-
|
|
292
|
+
return {
|
|
293
|
+
type: input.type === 'down' ? 'mousePressed' : 'mouseReleased',
|
|
294
|
+
...at,
|
|
295
|
+
button: 'left',
|
|
296
|
+
buttons: input.type === 'down' ? 1 : 0,
|
|
297
|
+
clickCount: input.clickCount,
|
|
298
|
+
modifiers,
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A key input as CDP takes it. A key that types sends `keyDown` with its text
|
|
305
|
+
* only when no modifier but Shift is held, as Chromium's own keyboard does, so
|
|
306
|
+
* `Control+K` in a focused field inserts no K.
|
|
307
|
+
*/
|
|
308
|
+
function keyEvent(input: KeyInput): Record<string, unknown> {
|
|
309
|
+
const descriptor = describeKey(input.key, {
|
|
310
|
+
shift: input.modifiers.includes('Shift'),
|
|
311
|
+
})
|
|
312
|
+
if (!descriptor) throw new Error(`no key descriptor for ${input.key}`)
|
|
313
|
+
const event = {
|
|
314
|
+
key: descriptor.key,
|
|
315
|
+
code: descriptor.code,
|
|
316
|
+
windowsVirtualKeyCode: descriptor.keyCode,
|
|
317
|
+
nativeVirtualKeyCode: descriptor.keyCode,
|
|
318
|
+
location: descriptor.location,
|
|
319
|
+
modifiers: modifierBits(input.modifiers),
|
|
320
|
+
}
|
|
321
|
+
if (input.type === 'up') return { type: 'keyUp', ...event }
|
|
322
|
+
const types =
|
|
323
|
+
descriptor.text !== undefined &&
|
|
324
|
+
input.modifiers.every((modifier) => modifier === 'Shift')
|
|
325
|
+
return types
|
|
326
|
+
? {
|
|
327
|
+
type: 'keyDown',
|
|
328
|
+
...event,
|
|
329
|
+
text: descriptor.text,
|
|
330
|
+
unmodifiedText: descriptor.text,
|
|
331
|
+
}
|
|
332
|
+
: { type: 'rawKeyDown', ...event }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Steps a frame, then awaits the input sent ahead of it. When the step fails,
|
|
337
|
+
* the input is left settling against a shell about to close, so its rejection
|
|
338
|
+
* is handled here rather than surfacing as an unhandled one.
|
|
339
|
+
*/
|
|
340
|
+
async function stepThen(
|
|
341
|
+
sent: Promise<unknown>,
|
|
342
|
+
step: () => Promise<void>,
|
|
343
|
+
): Promise<void> {
|
|
344
|
+
try {
|
|
345
|
+
await step()
|
|
346
|
+
} catch (error) {
|
|
347
|
+
sent.catch(() => {})
|
|
348
|
+
throw error
|
|
248
349
|
}
|
|
350
|
+
await sent
|
|
249
351
|
}
|
|
250
352
|
|
|
251
353
|
async function withinStall<T>(
|
|
@@ -257,6 +359,8 @@ async function withinStall<T>(
|
|
|
257
359
|
const stalled = new Promise<never>((_, fail) => {
|
|
258
360
|
timer = setTimeout(() => fail(new StepperStalled(frame, stallMs)), stallMs)
|
|
259
361
|
})
|
|
362
|
+
// A stall leaves the work pending until the shell closes and rejects it.
|
|
363
|
+
work.catch(() => {})
|
|
260
364
|
try {
|
|
261
365
|
return await Promise.race([work, stalled])
|
|
262
366
|
} finally {
|
package/src/tasks/archive.ts
CHANGED
|
@@ -325,6 +325,57 @@ export function readPendingBranches(text: string): readonly string[] {
|
|
|
325
325
|
.filter((entry) => entry.length > 0)
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
/**
|
|
329
|
+
* A local session's full sessionId, or the `session_` id a cloud create reads
|
|
330
|
+
* out. Only the full form reaches `canon sessions export`, so a short prefix is
|
|
331
|
+
* refused rather than written.
|
|
332
|
+
*/
|
|
333
|
+
const SESSION_ID_SOURCE =
|
|
334
|
+
'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|session_[A-Za-z0-9]+'
|
|
335
|
+
|
|
336
|
+
export const SESSION_ID_PATTERN = new RegExp(`^(?:${SESSION_ID_SOURCE})$`)
|
|
337
|
+
|
|
338
|
+
export const SESSIONS_PREFIX = 'Sessions:'
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* One entry is a name and its parenthesized id, and the parse keys on that
|
|
342
|
+
* trailing id, so a roster name carrying a comma or its own parentheses still
|
|
343
|
+
* reads as one entry.
|
|
344
|
+
*/
|
|
345
|
+
const SESSION_ENTRY_PATTERN = new RegExp(
|
|
346
|
+
`(.+?) \\((${SESSION_ID_SOURCE})\\)(?:, |$)`,
|
|
347
|
+
'y',
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
export interface SessionEntry {
|
|
351
|
+
readonly name: string
|
|
352
|
+
readonly sessionId: string
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Reads the `Sessions:` line `canon tasks session` writes. A line that does not
|
|
357
|
+
* parse whole reads as naming none, the way `readPullRequest` reads a line
|
|
358
|
+
* carrying anything besides its list.
|
|
359
|
+
*/
|
|
360
|
+
export function readSessions(text: string): readonly SessionEntry[] {
|
|
361
|
+
const line = text
|
|
362
|
+
.split('\n')
|
|
363
|
+
.find((entry) => entry.startsWith(SESSIONS_PREFIX))
|
|
364
|
+
if (line === undefined) return []
|
|
365
|
+
|
|
366
|
+
const body = line.slice(SESSIONS_PREFIX.length).trim()
|
|
367
|
+
const entries: SessionEntry[] = []
|
|
368
|
+
const pattern = new RegExp(SESSION_ENTRY_PATTERN)
|
|
369
|
+
|
|
370
|
+
while (pattern.lastIndex < body.length) {
|
|
371
|
+
const match = pattern.exec(body)
|
|
372
|
+
if (!match) return []
|
|
373
|
+
entries.push({ name: match[1], sessionId: match[2] })
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return entries
|
|
377
|
+
}
|
|
378
|
+
|
|
328
379
|
/** A markdown link on a `Plan:` line, its target captured. */
|
|
329
380
|
const PLAN_LINK_PATTERN = /\[[^\]]*\]\(([^)]+)\)/g
|
|
330
381
|
|
|
@@ -514,7 +565,8 @@ export function declineLine(reason: string, by: string, date: string): string {
|
|
|
514
565
|
/**
|
|
515
566
|
* Lines a `Declined:` line anchors after, mirroring `record.ts`'s
|
|
516
567
|
* `ORIGIN_PREFIXES` with `Pull request:` folded in, since a decline can follow
|
|
517
|
-
* a pull request that never merged.
|
|
568
|
+
* a pull request that never merged. `Sessions:` joins them, since the sessions
|
|
569
|
+
* that worked a task sit above whatever ended it.
|
|
518
570
|
*/
|
|
519
571
|
const DECLINE_ANCHOR_PREFIXES = [
|
|
520
572
|
'Plan:',
|
|
@@ -522,6 +574,7 @@ const DECLINE_ANCHOR_PREFIXES = [
|
|
|
522
574
|
'Intake:',
|
|
523
575
|
'Issue:',
|
|
524
576
|
'Pull request:',
|
|
577
|
+
SESSIONS_PREFIX,
|
|
525
578
|
] as const
|
|
526
579
|
|
|
527
580
|
function lastAnchorLine(lines: readonly string[]): number | undefined {
|