@junejuly-lockstep/reconciler 0.1.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +182 -0
  3. package/dist/boot.d.ts +51 -0
  4. package/dist/boot.d.ts.map +1 -0
  5. package/dist/boot.js +162 -0
  6. package/dist/boot.js.map +1 -0
  7. package/dist/cli.d.ts +4 -0
  8. package/dist/cli.d.ts.map +1 -0
  9. package/dist/cli.js +180 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +55 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +140 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/contract.d.ts +61 -0
  16. package/dist/contract.d.ts.map +1 -0
  17. package/dist/contract.js +154 -0
  18. package/dist/contract.js.map +1 -0
  19. package/dist/determinism.d.ts +23 -0
  20. package/dist/determinism.d.ts.map +1 -0
  21. package/dist/determinism.js +102 -0
  22. package/dist/determinism.js.map +1 -0
  23. package/dist/errors.d.ts +25 -0
  24. package/dist/errors.d.ts.map +1 -0
  25. package/dist/errors.js +35 -0
  26. package/dist/errors.js.map +1 -0
  27. package/dist/exitCodes.d.ts +33 -0
  28. package/dist/exitCodes.d.ts.map +1 -0
  29. package/dist/exitCodes.js +49 -0
  30. package/dist/exitCodes.js.map +1 -0
  31. package/dist/extract.d.ts +30 -0
  32. package/dist/extract.d.ts.map +1 -0
  33. package/dist/extract.js +376 -0
  34. package/dist/extract.js.map +1 -0
  35. package/dist/index.d.ts +35 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +19 -0
  38. package/dist/index.js.map +1 -0
  39. package/dist/init.d.ts +17 -0
  40. package/dist/init.d.ts.map +1 -0
  41. package/dist/init.js +55 -0
  42. package/dist/init.js.map +1 -0
  43. package/dist/journeys.d.ts +34 -0
  44. package/dist/journeys.d.ts.map +1 -0
  45. package/dist/journeys.js +276 -0
  46. package/dist/journeys.js.map +1 -0
  47. package/dist/paths.d.ts +26 -0
  48. package/dist/paths.d.ts.map +1 -0
  49. package/dist/paths.js +80 -0
  50. package/dist/paths.js.map +1 -0
  51. package/dist/reconcile.d.ts +63 -0
  52. package/dist/reconcile.d.ts.map +1 -0
  53. package/dist/reconcile.js +316 -0
  54. package/dist/reconcile.js.map +1 -0
  55. package/dist/report.d.ts +33 -0
  56. package/dist/report.d.ts.map +1 -0
  57. package/dist/report.js +97 -0
  58. package/dist/report.js.map +1 -0
  59. package/dist/sync.d.ts +46 -0
  60. package/dist/sync.d.ts.map +1 -0
  61. package/dist/sync.js +110 -0
  62. package/dist/sync.js.map +1 -0
  63. package/dist/upload.d.ts +33 -0
  64. package/dist/upload.d.ts.map +1 -0
  65. package/dist/upload.js +86 -0
  66. package/dist/upload.js.map +1 -0
  67. package/dist/util.d.ts +24 -0
  68. package/dist/util.d.ts.map +1 -0
  69. package/dist/util.js +97 -0
  70. package/dist/util.js.map +1 -0
  71. package/dist/visual.d.ts +49 -0
  72. package/dist/visual.d.ts.map +1 -0
  73. package/dist/visual.js +145 -0
  74. package/dist/visual.js.map +1 -0
  75. package/dist/walk.d.ts +147 -0
  76. package/dist/walk.d.ts.map +1 -0
  77. package/dist/walk.js +433 -0
  78. package/dist/walk.js.map +1 -0
  79. package/package.json +78 -0
  80. package/templates/example.journey.ts +90 -0
  81. package/templates/lockstep-reconcile.yml +219 -0
  82. package/templates/lockstep.config.example.ts +46 -0
  83. package/templates/msw-handlers.example.ts +113 -0
  84. package/templates/scenarios.example.ts +32 -0
@@ -0,0 +1,219 @@
1
+ # Lockstep client contract, item 6. Commit this file as .github/workflows/lockstep-reconcile.yml
2
+ # and add the project API key to the repo secrets as LOCKSTEP_API_KEY.
3
+ #
4
+ # Runs:
5
+ # push (default branch) -> model sync: `lockstep sync-models` loads the journey files and
6
+ # scenarios and posts the parsed models to the platform. The platform
7
+ # never executes client code, so this job is how the canvas learns about
8
+ # a model change (its webhook only sees that a newer commit exists).
9
+ # pull_request -> report-only run (kind pr, env preview). Verdicts never fail the check.
10
+ # deployment_status -> deploy run on a successful deployment. Exit 3 (verdicts need judgment)
11
+ # fails the workflow so a red deploy is visible in GitHub and on the board.
12
+ # workflow_dispatch -> manual run of the checked-out commit (Actions -> Lockstep reconcile ->
13
+ # Run workflow): the deploy-run path for repos whose hosting never emits
14
+ # deployment_status, and a way to re-run a deploy run by hand. Inputs: kind
15
+ # (deploy|pr), env (staging|prod|preview), deploy_label (default d<run
16
+ # number>), pr (only for kind pr). Uses --fail-on-verdicts auto: a deploy
17
+ # run fails on verdicts, a pr run is report-only.
18
+ # repository_dispatch (lockstep-build) -> placeholder for the client's coding agent.
19
+ name: Lockstep reconcile
20
+
21
+ on:
22
+ push:
23
+ pull_request:
24
+ deployment_status:
25
+ workflow_dispatch:
26
+ inputs:
27
+ kind:
28
+ description: Run kind
29
+ type: choice
30
+ options: [deploy, pr]
31
+ default: deploy
32
+ env:
33
+ description: Environment the run is recorded against
34
+ type: choice
35
+ options: [staging, prod, preview]
36
+ default: staging
37
+ deploy_label:
38
+ description: Deploy label, e.g. d14 (empty = d<run number>)
39
+ type: string
40
+ default: ''
41
+ pr:
42
+ description: Pull request number (only for kind pr)
43
+ type: string
44
+ default: ''
45
+ repository_dispatch:
46
+ types: [lockstep-build]
47
+
48
+ concurrency:
49
+ group: lockstep-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.deployment.sha || github.run_id }}
50
+ cancel-in-progress: false
51
+
52
+ permissions:
53
+ contents: read
54
+ pull-requests: read
55
+
56
+ jobs:
57
+ # Fast path, no browser and no build: a push to the default branch mirrors the journey models
58
+ # to the platform within a minute so the canvas updates on its next 10 s poll.
59
+ sync-models:
60
+ if: github.event_name == 'push' && github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
61
+ runs-on: ubuntu-latest
62
+ timeout-minutes: 10
63
+ env:
64
+ LOCKSTEP_API_KEY: ${{ secrets.LOCKSTEP_API_KEY }}
65
+ # The reconciler never needs browsers for a model sync.
66
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
67
+ steps:
68
+ - uses: actions/checkout@v4
69
+
70
+ - uses: pnpm/action-setup@v4
71
+
72
+ - uses: actions/setup-node@v4
73
+ with:
74
+ node-version: 22
75
+ cache: pnpm
76
+
77
+ - run: pnpm install --frozen-lockfile
78
+
79
+ - name: Sync journey models to Lockstep
80
+ run: >-
81
+ npx lockstep sync-models
82
+ --ref "$GITHUB_SHA"
83
+ --branch "$GITHUB_REF_NAME"
84
+ --pushed-at "${{ github.event.head_commit.timestamp }}"
85
+
86
+ reconcile:
87
+ if: >-
88
+ github.event_name == 'pull_request' ||
89
+ github.event_name == 'workflow_dispatch' ||
90
+ (github.event_name == 'deployment_status' && github.event.deployment_status.state == 'success')
91
+ runs-on: ubuntu-latest
92
+ timeout-minutes: 30
93
+ env:
94
+ LOCKSTEP_API_KEY: ${{ secrets.LOCKSTEP_API_KEY }}
95
+ # deployment_status: the deployed commit; pull_request: the head commit; workflow_dispatch: the
96
+ # commit of the branch or tag the run was started from (GITHUB_SHA).
97
+ SHA: ${{ github.event_name == 'deployment_status' && github.event.deployment.sha || github.event.pull_request.head.sha || github.sha }}
98
+ TZ: UTC
99
+ steps:
100
+ - uses: actions/checkout@v4
101
+ with:
102
+ ref: ${{ env.SHA }}
103
+ # The contract diff compares the SDL against the previous commit.
104
+ fetch-depth: 2
105
+
106
+ - uses: pnpm/action-setup@v4
107
+
108
+ - uses: actions/setup-node@v4
109
+ with:
110
+ node-version: 22
111
+ cache: pnpm
112
+
113
+ - run: pnpm install --frozen-lockfile
114
+
115
+ - name: Install Chromium for Playwright
116
+ run: pnpm exec playwright install --with-deps chromium
117
+
118
+ - name: Build the app
119
+ run: pnpm build
120
+
121
+ - name: Validate the Lockstep contract
122
+ run: npx lockstep validate
123
+
124
+ - name: Reconcile (pull request, report-only)
125
+ if: github.event_name == 'pull_request'
126
+ run: >-
127
+ npx lockstep reconcile
128
+ --ref "$SHA"
129
+ --kind pr
130
+ --pr ${{ github.event.pull_request.number }}
131
+ --env preview
132
+ --fail-on-verdicts never
133
+
134
+ - name: Reconcile (deploy, fails on verdicts)
135
+ if: github.event_name == 'deployment_status'
136
+ run: >-
137
+ npx lockstep reconcile
138
+ --ref "$SHA"
139
+ --kind deploy
140
+ --env ${{ github.event.deployment.environment == 'production' && 'prod' || 'staging' }}
141
+ --deploy-label "d${{ github.run_number }}"
142
+ --fail-on-verdicts always
143
+
144
+ # Manual deploy-run path (no deployment_status needed). `auto` = fail on verdicts for kind
145
+ # deploy, report-only for kind pr; --pr and --deploy-label are only passed when they apply.
146
+ - name: Reconcile (manual run)
147
+ if: github.event_name == 'workflow_dispatch'
148
+ env:
149
+ KIND: ${{ inputs.kind }}
150
+ ENV_NAME: ${{ inputs.env }}
151
+ DEPLOY_LABEL: ${{ inputs.deploy_label != '' && inputs.deploy_label || format('d{0}', github.run_number) }}
152
+ PR: ${{ inputs.pr }}
153
+ run: |
154
+ set -- --ref "$GITHUB_SHA" --kind "$KIND" --env "$ENV_NAME" --fail-on-verdicts auto
155
+ if [ "$KIND" = "deploy" ]; then set -- "$@" --deploy-label "$DEPLOY_LABEL"; fi
156
+ if [ -n "$PR" ]; then set -- "$@" --pr "$PR"; fi
157
+ echo "npx lockstep reconcile $*"
158
+ npx lockstep reconcile "$@"
159
+
160
+ - name: Upload local reconcile output
161
+ if: always()
162
+ uses: actions/upload-artifact@v4
163
+ with:
164
+ name: lockstep-${{ github.run_id }}
165
+ path: .lockstep
166
+ retention-days: 14
167
+ if-no-files-found: ignore
168
+
169
+ # ------------------------------------------------------------------------------------------
170
+ # Agent build placeholder. The platform dispatches `lockstep-build` from POST /api/cards/:id/dispatch
171
+ # with client_payload { cardId, cardRef, branch, workOrder }. The agent runner itself is out of
172
+ # Lockstep's scope: wire your coding agent in the step marked below.
173
+ #
174
+ # The only contract back to the platform: the PR this job opens must contain the card ref
175
+ # (for example "FERN-12") in its body. The Lockstep GitHub App webhook links the PR to the card
176
+ # on open; merge plus a clean reconcile walks the card to In lockstep.
177
+ # ------------------------------------------------------------------------------------------
178
+ agent-build:
179
+ if: github.event_name == 'repository_dispatch' && github.event.action == 'lockstep-build'
180
+ runs-on: ubuntu-latest
181
+ timeout-minutes: 60
182
+ permissions:
183
+ contents: write
184
+ pull-requests: write
185
+ env:
186
+ CARD_ID: ${{ github.event.client_payload.cardId }}
187
+ CARD_REF: ${{ github.event.client_payload.cardRef }}
188
+ BRANCH: ${{ github.event.client_payload.branch }}
189
+ steps:
190
+ - uses: actions/checkout@v4
191
+
192
+ - name: Show the work order
193
+ run: |
194
+ echo "Card: $CARD_REF ($CARD_ID)"
195
+ echo "Branch: $BRANCH"
196
+ echo '${{ toJson(github.event.client_payload.workOrder) }}' > work-order.json
197
+ cat work-order.json
198
+
199
+ # ----------------------------------------------------------------------------------------
200
+ # WIRE YOUR CODING AGENT HERE.
201
+ # Input: work-order.json (brief, machine delta, scenario fixtures, acceptance paths, bindings).
202
+ # Output: commits on $BRANCH that implement the journey and keep the contract green.
203
+ # Example: run your agent CLI against work-order.json, then `git push origin HEAD:$BRANCH`.
204
+ # ----------------------------------------------------------------------------------------
205
+ - name: Run coding agent (placeholder)
206
+ run: |
207
+ echo "No agent configured. Replace this step with your agent runner."
208
+ exit 0
209
+
210
+ # The PR body MUST include the card ref so the platform can link it. Keep the first line.
211
+ - name: Open the pull request
212
+ if: false # set to true once the agent step above produces commits on $BRANCH
213
+ env:
214
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
215
+ run: |
216
+ gh pr create \
217
+ --head "$BRANCH" \
218
+ --title "$CARD_REF: $(jq -r '.brief | split("\n")[0]' work-order.json)" \
219
+ --body "$(printf 'Lockstep card %s\n\n%s\n\nGenerated from work order %s.' "$CARD_REF" "$(jq -r .brief work-order.json)" "$CARD_ID")"
@@ -0,0 +1,46 @@
1
+ import { defineConfig } from '@junejuly-lockstep/reconciler';
2
+
3
+ /**
4
+ * Lockstep client contract, item 1. Lives at the repo root as `lockstep.config.ts`.
5
+ * Every relative path resolves from this file's directory.
6
+ */
7
+ export default defineConfig({
8
+ /** Project key on the platform; cards render as FERN-12. Uppercase, 2-8 chars. */
9
+ projectKey: 'FERN',
10
+
11
+ /** Platform API origin the reconciler posts runs to. */
12
+ apiBaseUrl: 'https://api.lockstep.example.com',
13
+
14
+ /** One file per journey, each exporting `machine` (XState v5 createMachine) and `meta`. */
15
+ journeysGlob: 'src/journeys/*.journey.ts',
16
+
17
+ /** Named personas: seed plus mock overrides. */
18
+ scenariosPath: 'src/journeys/scenarios.ts',
19
+
20
+ /** Ordered seam strip, 6 to 8 names. Backend changes tag the seams they touch. */
21
+ seams: ['Client app', 'API layer', 'Scheduling', 'Patient records', 'Notifications', 'Identity'],
22
+
23
+ /** Fraction of differing pixels above which a screenshot is `changed`. */
24
+ visualDiffThreshold: 0.001,
25
+
26
+ /**
27
+ * How the reconciler boots the app under test. The command receives PORT, LOCKSTEP_SCENARIO
28
+ * and LOCKSTEP_SEED in its environment; the walker also appends `?lockstep_scenario=` to the
29
+ * first navigation so the MSW layer can seed itself in the browser.
30
+ */
31
+ bootCommand: 'pnpm exec next start --port 3100',
32
+ port: 3100,
33
+
34
+ /**
35
+ * Security rule: walks only ever hit mocks. `appApiBase` is the origin the app talks to and
36
+ * must match `mockApiPattern`, otherwise the reconciler refuses to run.
37
+ */
38
+ appApiBase: 'msw://patient-api',
39
+ mockApiPattern: '^(mock|msw):|localhost|127\\.0\\.0\\.1',
40
+
41
+ /** GraphQL contract: the SDL and the graphql-codegen TypeScript output. */
42
+ schema: {
43
+ sdlPath: 'schema.graphql',
44
+ codegenOutput: 'src/gql/graphql.ts',
45
+ },
46
+ });
@@ -0,0 +1,113 @@
1
+ import { graphql, HttpResponse } from 'msw';
2
+ import { scenarios } from '../journeys/scenarios';
3
+
4
+ /**
5
+ * Lockstep client contract, item 4: MSW handlers that are deterministic under a fixed seed.
6
+ *
7
+ * Rules that keep screenshots byte-identical run to run:
8
+ * - No faker, no Math.random, no Date.now(). Every value derives from the seed below or from
9
+ * the frozen epoch the walker installs (FROZEN_EPOCH_MS in @junejuly-lockstep/reconciler).
10
+ * - The scenario comes from `?lockstep_scenario=` in the browser or LOCKSTEP_SCENARIO on the
11
+ * server; both are set by the reconciler. Default to the first scenario in dev.
12
+ * - Shapes follow the generated types from graphql-codegen so a schema change shows up here
13
+ * as a type error before it shows up as a contract verdict.
14
+ */
15
+
16
+ /** mulberry32: tiny, fast, fully deterministic PRNG. */
17
+ export function seeded(seed: number): () => number {
18
+ let a = seed >>> 0;
19
+ return () => {
20
+ a = (a + 0x6d2b79f5) >>> 0;
21
+ let t = a;
22
+ t = Math.imul(t ^ (t >>> 15), t | 1);
23
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
24
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
25
+ };
26
+ }
27
+
28
+ const CLINICIANS = ['Dr. Amara Okafor', 'Dr. Ben Liu', 'Dr. Carla Mendes', 'Dr. Dev Patel'];
29
+ const LOCATIONS = ['Downtown clinic', 'Riverside clinic', 'Telehealth'];
30
+
31
+ /** The walker freezes the page clock here; derive every date from it, never from Date.now(). */
32
+ export const FROZEN_EPOCH_MS = Date.UTC(2026, 0, 15, 9, 30, 0);
33
+
34
+ export function currentScenario(): (typeof scenarios)[number] {
35
+ let name: string | undefined;
36
+ if (typeof window !== 'undefined') {
37
+ name = new URLSearchParams(window.location.search).get('lockstep_scenario') ?? undefined;
38
+ if (name) window.sessionStorage.setItem('lockstep_scenario', name);
39
+ name ??= window.sessionStorage.getItem('lockstep_scenario') ?? undefined;
40
+ } else if (typeof process !== 'undefined') {
41
+ name = process.env['LOCKSTEP_SCENARIO'];
42
+ }
43
+ return scenarios.find((s) => s.name === name) ?? scenarios[0]!;
44
+ }
45
+
46
+ function pick<T>(rand: () => number, list: readonly T[]): T {
47
+ return list[Math.floor(rand() * list.length)]!;
48
+ }
49
+
50
+ function slotsFor(scenario: (typeof scenarios)[number]) {
51
+ const rand = seeded(scenario.seed);
52
+ const count =
53
+ typeof scenario.overrides['availableSlots'] === 'number'
54
+ ? (scenario.overrides['availableSlots'] as number)
55
+ : 6;
56
+ const slots = [];
57
+ for (let i = 0; i < count; i++) {
58
+ const dayOffset = 1 + Math.floor(rand() * 7);
59
+ const hour = 9 + Math.floor(rand() * 8);
60
+ slots.push({
61
+ id: `slot-${scenario.seed}-${i}`,
62
+ startsAt: new Date(FROZEN_EPOCH_MS + dayOffset * 86_400_000 + hour * 3_600_000).toISOString(),
63
+ clinician: pick(rand, CLINICIANS),
64
+ location: pick(rand, LOCATIONS),
65
+ available: true,
66
+ });
67
+ }
68
+ return slots.sort((a, b) => a.startsAt.localeCompare(b.startsAt));
69
+ }
70
+
71
+ export const handlers = [
72
+ graphql.query('Viewer', () => {
73
+ const scenario = currentScenario();
74
+ const rand = seeded(scenario.seed);
75
+ return HttpResponse.json({
76
+ data: {
77
+ viewer: {
78
+ id: `patient-${scenario.seed}`,
79
+ firstName: scenario.name.split('-')[0]!.replace(/^./, (c) => c.toUpperCase()),
80
+ eligible: true,
81
+ upcomingVisits: Array.from(
82
+ { length: Number(scenario.overrides['upcomingVisits'] ?? 0) },
83
+ (_, i) => ({
84
+ id: `visit-${scenario.seed}-${i}`,
85
+ startsAt: new Date(FROZEN_EPOCH_MS + (i + 2) * 86_400_000).toISOString(),
86
+ clinician: pick(rand, CLINICIANS),
87
+ }),
88
+ ),
89
+ },
90
+ },
91
+ });
92
+ }),
93
+
94
+ graphql.query('Slots', () => {
95
+ return HttpResponse.json({ data: { slots: slotsFor(currentScenario()) } });
96
+ }),
97
+
98
+ graphql.mutation('BookVisit', ({ variables }) => {
99
+ const scenario = currentScenario();
100
+ const slot =
101
+ slotsFor(scenario).find((s) => s.id === (variables as { slotId?: string }).slotId) ??
102
+ slotsFor(scenario)[0];
103
+ return HttpResponse.json({
104
+ data: {
105
+ bookVisit: {
106
+ id: `booking-${scenario.seed}`,
107
+ reference: `FERN-${String(scenario.seed).padStart(4, '0')}`,
108
+ slot,
109
+ },
110
+ },
111
+ });
112
+ }),
113
+ ];
@@ -0,0 +1,32 @@
1
+ import type { Scenario } from '@junejuly-lockstep/reconciler';
2
+
3
+ /**
4
+ * Lockstep client contract, item 3: named personas. Each scenario is a seed plus overrides for
5
+ * the schema-generated mock handlers. The reconciler boots the app once per scenario with
6
+ * LOCKSTEP_SCENARIO=<name> and LOCKSTEP_SEED=<seed>, and opens the first route with
7
+ * `?lockstep_scenario=<name>&lockstep_seed=<seed>`.
8
+ *
9
+ * Keep seeds stable: changing a seed changes every screenshot and forces a baseline review.
10
+ */
11
+ export const scenarios: Scenario[] = [
12
+ {
13
+ name: 'nora',
14
+ description: 'Nora, eligible, no visit history',
15
+ seed: 1,
16
+ overrides: {},
17
+ },
18
+ {
19
+ name: 'omar',
20
+ description: 'Omar, returning, one upcoming visit, two slots left this week',
21
+ seed: 2,
22
+ overrides: { upcomingVisits: 1, availableSlots: 2 },
23
+ },
24
+ {
25
+ name: 'priya-no-slots',
26
+ description: 'Priya, eligible, no slots available in the next seven days',
27
+ seed: 3,
28
+ overrides: { availableSlots: 0 },
29
+ },
30
+ ];
31
+
32
+ export default scenarios;