@company-semantics/contracts 90.0.0 → 91.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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/affordances/README.md +65 -0
  3. package/src/affordances/__tests__/README.md +30 -0
  4. package/src/affordances/__tests__/affordances.test.ts +251 -0
  5. package/src/affordances/affordance.ts +124 -0
  6. package/src/affordances/args.ts +59 -0
  7. package/src/affordances/availability.ts +78 -0
  8. package/src/affordances/ids.ts +45 -0
  9. package/src/affordances/index.ts +39 -0
  10. package/src/api/generated-spec-hash.ts +2 -2
  11. package/src/api/generated.ts +458 -2
  12. package/src/generated/openapi-routes.ts +4 -0
  13. package/src/proactive/README.md +26 -4
  14. package/src/proactive/__tests__/README.md +10 -0
  15. package/src/proactive/__tests__/chat-templates.test.ts +8 -0
  16. package/src/proactive/__tests__/vocabulary.test.ts +11 -0
  17. package/src/proactive/__tests__/workflow-attention-fixtures.ts +49 -0
  18. package/src/proactive/__tests__/workflow-attention.test.ts +146 -0
  19. package/src/proactive/composer.ts +8 -0
  20. package/src/proactive/facts.ts +36 -0
  21. package/src/proactive/index.ts +2 -0
  22. package/src/proactive/kinds.ts +29 -0
  23. package/src/proactive/registry.ts +22 -0
  24. package/src/proactive/surfaces.ts +2 -0
  25. package/src/proactive/templates/README.md +9 -1
  26. package/src/proactive/templates/index.ts +2 -0
  27. package/src/proactive/templates/workflow-needs-attention.ts +35 -0
  28. package/src/root-exports/README.md +1 -0
  29. package/src/root-exports/delivery.ts +69 -1
  30. package/src/user-notifications/README.md +8 -0
  31. package/src/user-notifications/kinds.ts +13 -0
  32. package/src/user-notifications/lifecycle-registry.ts +10 -0
  33. package/src/workflows/README.md +44 -6
  34. package/src/workflows/__tests__/README.md +21 -2
  35. package/src/workflows/__tests__/affordances-of-outcome.test.ts +96 -0
  36. package/src/workflows/__tests__/deferred-route.test.ts +167 -0
  37. package/src/workflows/__tests__/outcome.test.ts +68 -3
  38. package/src/workflows/affordances-of-outcome.ts +64 -0
  39. package/src/workflows/deferred-route.ts +242 -0
  40. package/src/workflows/index.ts +51 -11
  41. package/src/workflows/outcome-reasons.ts +244 -0
  42. package/src/workflows/outcome.ts +50 -148
@@ -201,6 +201,8 @@ export type {
201
201
  ProactiveEventDefinition,
202
202
  OrgBecameSharedFacts,
203
203
  MorningBriefFacts,
204
+ ExplainedWorkflowOutcomeData,
205
+ WorkflowNeedsAttentionFacts,
204
206
  ProactiveChatFacts,
205
207
  ProactiveChatComposer,
206
208
  ProactiveChatMessage,
@@ -219,31 +221,60 @@ export type { WorkflowType } from "../workflows/index";
219
221
 
220
222
  // The typed outcome of a workflow's terminal transition (control ADR-CTRL-540):
221
223
  // per-type closed reasons, typed facts and an explicit disposition, classified
222
- // once by the backend and frozen. See src/workflows/outcome.ts.
224
+ // once by the backend and frozen. Each ending also freezes the next steps its
225
+ // reason offers (control ADR affordance-system); `affordancesOfOutcome` is the
226
+ // one projection consumers render from. An ending with no launching
227
+ // conversation takes its reason's deferred route (explained | notice | covered
228
+ // | silent). See src/workflows/outcome.ts and src/workflows/deferred-route.ts.
223
229
  export {
230
+ DEFERRED_ROUTES,
231
+ DISCOVER_AFFORDANCES_OF_REASON,
232
+ DISCOVER_DEFERRED_ROUTE_OF_REASON,
224
233
  DISCOVER_OUTCOME_KIND_OF_REASON,
234
+ DeferredRouteSchema,
225
235
  DiscoverOutcomeFactsSchema,
226
236
  DiscoverWorkflowOutcomeSchema,
227
237
  HrisConnectWorkflowOutcomeSchema,
238
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
239
+ ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON,
228
240
  ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
229
241
  OrgStructureOutcomeFactsSchema,
230
242
  ReanalyzeWorkflowOutcomeSchema,
243
+ SETTLEMENT_AUDIENCE_POLICIES,
244
+ SETTLEMENT_COVERING_KINDS,
245
+ SETTLEMENT_SILENT_REASONS,
246
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
247
+ SYNTHESIZE_DEFERRED_ROUTE_OF_REASON,
231
248
  SYNTHESIZE_OUTCOME_KIND_OF_REASON,
232
249
  SynthesizeOutcomeFactsSchema,
233
250
  SynthesizeWorkflowOutcomeSchema,
251
+ WORKFLOW_AFFORDANCES_OF_REASON,
252
+ WORKFLOW_DEFERRED_ROUTE_OF_REASON,
234
253
  WORKFLOW_OUTCOME_KINDS,
235
254
  WorkflowOutcomeDispositionSchema,
236
255
  WorkflowOutcomeSchema,
256
+ affordancesOfOutcome,
257
+ affordancesOfReason,
258
+ deferredRouteOf,
259
+ toExplainedOutcome,
237
260
  } from "../workflows/index";
238
261
 
239
262
  export type {
263
+ DeferredRoute,
264
+ DeferredRouteKind,
240
265
  DiscoverOutcomeFacts,
241
266
  DiscoverOutcomeReason,
242
267
  DiscoverWorkflowOutcome,
268
+ ExplainedDiscoverReason,
269
+ ExplainedOrgStructureReason,
270
+ ExplainedSynthesizeReason,
271
+ ExplainedWorkflowOutcome,
243
272
  HrisConnectWorkflowOutcome,
244
273
  OrgStructureOutcomeFacts,
245
274
  OrgStructureOutcomeReason,
246
275
  ReanalyzeWorkflowOutcome,
276
+ SettlementAudiencePolicy,
277
+ SettlementCoveringKind,
247
278
  SynthesizeOutcomeFacts,
248
279
  SynthesizeOutcomeReason,
249
280
  SynthesizeWorkflowOutcome,
@@ -252,3 +283,40 @@ export type {
252
283
  WorkflowOutcomeKind,
253
284
  WorkflowOutcomeOf,
254
285
  } from "../workflows/index";
286
+
287
+ // Affordances (control ADR affordance-system): the closed vocabulary of next
288
+ // steps the product may offer, as INSTANCES ({ id, args? }), with each one's
289
+ // binding kind and live availability condition. An affordance is offered only
290
+ // by a domain-owned deterministic rule; existing here never offers it. See
291
+ // src/affordances/README.md.
292
+ export {
293
+ AFFORDANCE_AVAILABILITY_IDS,
294
+ AFFORDANCE_AVAILABILITY_OF,
295
+ AFFORDANCE_ARGS,
296
+ AFFORDANCE_BINDING_KINDS,
297
+ AFFORDANCE_BINDING_KIND_OF,
298
+ AFFORDANCE_IDS,
299
+ AFFORDANCE_UNAVAILABLE_REASONS,
300
+ AffordanceAvailabilityIdSchema,
301
+ AffordanceAvailabilitySchema,
302
+ AffordanceIdSchema,
303
+ AffordanceSchema,
304
+ MAX_OFFERED_AFFORDANCES,
305
+ NO_ARG_AFFORDANCE_IDS,
306
+ OfferedAffordanceSchema,
307
+ PROJECTED_AFFORDANCE_IDS,
308
+ ReviewOpenArgsSchema,
309
+ sameAffordances,
310
+ } from "../affordances/index";
311
+
312
+ export type {
313
+ Affordance,
314
+ AffordanceArgs,
315
+ AffordanceAvailability,
316
+ AffordanceAvailabilityId,
317
+ AffordanceBindingKind,
318
+ AffordanceId,
319
+ AffordanceUnavailableReason,
320
+ OfferedAffordance,
321
+ ProjectedAffordanceId,
322
+ } from "../affordances/index";
@@ -78,6 +78,14 @@ that renders them alongside action items at the top of `/me/work`.
78
78
  - **`proactive.org_structure_review_ready` is the org structure notice's row.**
79
79
  It arrives UNREAD, because a `notice` has no other surface to hold the badge.
80
80
  The renderer links it to the place the review is opened.
81
+ - **`proactive.workflow_needs_attention` and `proactive.workflow_finished` are
82
+ the workflow settlement rows** (control ADR-CTRL-540 phase 2), the durable
83
+ twins of `workflow.needs_attention` (explained, so born READ) and
84
+ `workflow.finished` (a notice, so UNREAD). A row holds identifiers only — the
85
+ workflow id and type; a finished run's counts render at read time under the
86
+ reader's authority. Both supersede per workflow type (condition field
87
+ `workflowType`, renewing): one active row per workflow type per reader, and a
88
+ newer settlement is news again.
81
89
  - **This is a mention row, not a chat-unread model.** Being named is a discrete
82
90
  fact you come back to; "this chat has new messages" is not modeled here or
83
91
  anywhere, and the proactive `unread` flag on a chat summary stays a one-time
@@ -229,6 +229,19 @@ export const USER_NOTIFICATION_KINDS = [
229
229
  * where the review is opened.
230
230
  */
231
231
  "proactive.org_structure_review_ready",
232
+
233
+ // ===========================================================================
234
+ // Workflow settlements (control ADR-CTRL-540 phase 2)
235
+ // ===========================================================================
236
+ //
237
+ // The durable twins of `workflow.needs_attention` / `workflow.finished`,
238
+ // rendered by `src/proactive/` in the backend, DISTINCT from the kind strings.
239
+ // A row holds identifiers only (the workflow id and type); a `notice` row's
240
+ // counts render at read time under the reader's authority.
241
+ /** A failure a person can act on. Explained: the chat holds the badge, born read. */
242
+ "proactive.workflow_needs_attention",
243
+ /** A finished run nobody watched. A notice: this row IS the attention, unread. */
244
+ "proactive.workflow_finished",
232
245
  ] as const;
233
246
 
234
247
  export type UserNotificationKind = (typeof USER_NOTIFICATION_KINDS)[number];
@@ -32,6 +32,14 @@ const STANDING = {
32
32
  resolution: RESOLVED_FOR_ALL,
33
33
  retention: "standard",
34
34
  } as const;
35
+ /** One active row per workflow type per reader; a newer settlement renews it. */
36
+ const PER_WORKFLOW_TYPE = {
37
+ condition: { scope: "fields", fields: ["workflowType"] },
38
+ supersedes: true,
39
+ attentionOnSupersede: "renew",
40
+ resolution: "never",
41
+ retention: "standard",
42
+ } as const;
35
43
 
36
44
  /**
37
45
  * Every kind's lifecycle. Annotated (not `satisfies`) so a missing kind and an
@@ -120,4 +128,6 @@ export const NOTIFICATION_LIFECYCLE: Readonly<Record<UserNotificationKind, Notif
120
128
  resolution: RESOLVED_FOR_ALL,
121
129
  retention: "standard",
122
130
  },
131
+ "proactive.workflow_needs_attention": PER_WORKFLOW_TYPE,
132
+ "proactive.workflow_finished": PER_WORKFLOW_TYPE,
123
133
  };
@@ -32,7 +32,8 @@ so every producer, validator and renderer agrees on it.
32
32
  - **Source Sync registers two flows.** `source-sync/discover` (Stage 1)
33
33
  retains nothing; `source-sync/synthesize` (Stage 4) runs only after a person
34
34
  approved the exact set it reads.
35
- - **Every type has a typed outcome** (`outcome.ts`, control ADR-CTRL-540). A
35
+ - **Every type has a typed outcome** (`outcome.ts`, tables in
36
+ `outcome-reasons.ts`; control ADR-CTRL-540). A
36
37
  terminal transition is classified once, by the backend, into a per-type
37
38
  member of `WorkflowOutcome`: a closed `reason`, typed count-only `facts`, a
38
39
  `kind` that is a function of the reason (`*_OUTCOME_KIND_OF_REASON`), and an
@@ -40,15 +41,52 @@ so every producer, validator and renderer agrees on it.
40
41
  adding its outcome member; the union must cover every registered type.
41
42
  - **`unknown` is a safety valve, not a status.** It names an unrecognized
42
43
  terminal transition, never a workflow whose state could not be read.
44
+ - **The offer is a function of reason** (control ADR affordance-system). Every
45
+ outcome carries a REQUIRED `affordances` list — the ordered next steps the
46
+ ending offers — fixed by its reason's per-type `*_AFFORDANCES_OF_REASON`
47
+ table and frozen with the outcome. The tables are total (an empty list is an
48
+ explicit decision) and the schema refuses any other list, a missing key and
49
+ more than `MAX_OFFERED_AFFORDANCES`. Producers read the offer through
50
+ `affordancesOfReason` only.
51
+ - **`review.open` is projected, never stored.** It is derived from a `review`
52
+ disposition; an offer that contains it is refused. `affordancesOfOutcome` is
53
+ the single projection every consumer renders from: the frozen offer, then
54
+ `review.open` when the disposition is `review`.
55
+ - **A deferred settlement's route is a function of reason** (control
56
+ ADR-CTRL-540 phase 2). An ending with no launching conversation takes its
57
+ reason's entry in the total per-type `*_DEFERRED_ROUTE_OF_REASON` table:
58
+ `explained` only for a `failed` or `abandoned` ending that offers at least one
59
+ affordance (told fact-free via `toExplainedOutcome`), `notice` for anything
60
+ whose telling needs counts, `covered` when a domain proactive kind normally
61
+ announces it (always with a `fallback: "notice"` and an audience, because
62
+ the backend verifies the covering occurrence at route time), and `silent`
63
+ exactly for `stopped_by_user`. A `produced`, `empty` or `partial` ending is
64
+ never explained.
65
+ - **Workflows never imports proactive.** The covering kinds are literal
66
+ strings pinned against `PROACTIVE_EVENT_KIND_IDS` by a test; the dependency
67
+ runs proactive → workflows only.
43
68
 
44
69
  ## Public API
45
70
 
46
71
  - `ANALYSIS_WORKFLOW_TYPES`, `WorkflowType`, `isWorkflowType` (`types.ts`).
47
- - `WorkflowOutcomeSchema` / `WorkflowOutcome` and the per-type member schemas,
48
- reason tables, fact schemas and `WorkflowOutcomeDispositionSchema`
49
- (`outcome.ts`).
72
+ - `WorkflowOutcomeSchema` / `WorkflowOutcome`, the per-type member schemas and
73
+ `WorkflowOutcomeDispositionSchema` (`outcome.ts`).
74
+ - `WORKFLOW_OUTCOME_KINDS` and the per-type reason tables and fact schemas
75
+ (`outcome-reasons.ts`).
76
+ - `DISCOVER_AFFORDANCES_OF_REASON`, `SYNTHESIZE_AFFORDANCES_OF_REASON`,
77
+ `ORG_STRUCTURE_AFFORDANCES_OF_REASON` (`outcome-reasons.ts`).
78
+ - `WORKFLOW_AFFORDANCES_OF_REASON`, `affordancesOfReason`,
79
+ `affordancesOfOutcome` (`affordances-of-outcome.ts`).
80
+ - `SETTLEMENT_AUDIENCE_POLICIES`, `SETTLEMENT_COVERING_KINDS`,
81
+ `SETTLEMENT_SILENT_REASONS`, `DEFERRED_ROUTES`, `DeferredRouteSchema` /
82
+ `DeferredRoute`, the `*_DEFERRED_ROUTE_OF_REASON` tables,
83
+ `WORKFLOW_DEFERRED_ROUTE_OF_REASON`, `ExplainedWorkflowOutcome` and its
84
+ `Explained*Reason` types, `deferredRouteOf`, `toExplainedOutcome`
85
+ (`deferred-route.ts`).
50
86
 
51
87
  ## Dependencies
52
88
 
53
- `zod` (outcome schemas) and `../input-requests/types` (the review
54
- disposition's request type). Otherwise pure vocabulary data and one guard.
89
+ `zod` (outcome schemas), `../input-requests/types` (the review disposition's
90
+ request type) and `../affordances/affordance` (the offered-affordance schema,
91
+ its sanity bound and `sameAffordances`). Otherwise pure vocabulary data, one
92
+ guard and pure helpers.
@@ -17,7 +17,24 @@ wire value against it.
17
17
  (compile-time), each reason table's kinds are valid, the schema accepts a
18
18
  well-formed member and refuses a kind that does not match its reason, a
19
19
  review disposition on a non-produced outcome, a reason from another type,
20
- and extra keys (the facts stay closed).
20
+ and extra keys (the facts stay closed). Every reason has an explicit offer
21
+ of offerable affordances, and the schema refuses an offer that differs from
22
+ its reason's table, a missing `affordances` key, `review.open` inside a
23
+ frozen offer and more than `MAX_OFFERED_AFFORDANCES` entries.
24
+
25
+ - `affordances-of-outcome.test.ts` — `affordancesOfOutcome` projects
26
+ `review.open` after the frozen offer for a `review` disposition and nothing
27
+ extra for `narrate`; `affordancesOfReason` returns each workflow type's
28
+ table entry (both org-structure types share one table); every projected
29
+ list is a valid `Affordance` list.
30
+
31
+ - `deferred-route.test.ts` — every reason of every workflow type has a valid
32
+ deferred route; `explained` only for a `failed`/`abandoned` ending with a
33
+ non-empty offer; `produced`/`empty`/`partial` never explained; `silent` ⇔
34
+ `stopped_by_user`; every covering kind is a real proactive kind and every
35
+ covered route falls back to a notice with an audience; `toExplainedOutcome`
36
+ strips facts and stays schema-valid; and no `src/workflows/*.ts` file
37
+ imports from proactive.
21
38
 
22
39
  ## Invariants
23
40
 
@@ -33,4 +50,6 @@ None — test-only.
33
50
 
34
51
  ## Dependencies
35
52
 
36
- `vitest` and `../types`.
53
+ `vitest`, `node:fs`, `../types`, `../outcome`, `../affordances-of-outcome`,
54
+ `../deferred-route`, `../../affordances/affordance` and
55
+ `../../proactive/kinds` (test-only: pins the covering kinds).
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { AffordanceSchema } from "../../affordances/affordance";
4
+ import {
5
+ WORKFLOW_AFFORDANCES_OF_REASON,
6
+ affordancesOfOutcome,
7
+ affordancesOfReason,
8
+ } from "../affordances-of-outcome";
9
+ import { WorkflowOutcomeSchema, type WorkflowOutcome } from "../outcome";
10
+ import { ORG_STRUCTURE_AFFORDANCES_OF_REASON } from "../outcome-reasons";
11
+ import { ANALYSIS_WORKFLOW_TYPES } from "../types";
12
+
13
+ const review = {
14
+ kind: "review",
15
+ requestType: "source-sync/candidates",
16
+ requestId: "set-1",
17
+ } as const;
18
+
19
+ const produced: WorkflowOutcome = {
20
+ workflowType: "source-sync/discover",
21
+ executionEpoch: 0,
22
+ kind: "produced",
23
+ reason: "candidates_found",
24
+ facts: null,
25
+ disposition: review,
26
+ affordances: [],
27
+ };
28
+
29
+ const partial: WorkflowOutcome = {
30
+ ...produced,
31
+ kind: "partial",
32
+ reason: "budget_exhausted",
33
+ };
34
+
35
+ const driveAuth: WorkflowOutcome = {
36
+ ...produced,
37
+ kind: "failed",
38
+ reason: "drive_auth",
39
+ disposition: { kind: "narrate" },
40
+ affordances: [{ id: "integrations.connectGoogleDrive" }],
41
+ };
42
+
43
+ const REVIEW_OPEN = {
44
+ id: "review.open",
45
+ args: { requestType: "source-sync/candidates", requestId: "set-1" },
46
+ };
47
+
48
+ describe("affordancesOfOutcome", () => {
49
+ it("fixtures are valid outcomes", () => {
50
+ for (const outcome of [produced, partial, driveAuth]) {
51
+ expect(WorkflowOutcomeSchema.safeParse(outcome).success).toBe(true);
52
+ }
53
+ });
54
+
55
+ it("projects review.open from a review disposition, after the frozen offer", () => {
56
+ expect(affordancesOfOutcome(produced)).toEqual([REVIEW_OPEN]);
57
+ expect(affordancesOfOutcome(partial)).toEqual([
58
+ ...partial.affordances,
59
+ REVIEW_OPEN,
60
+ ]);
61
+ });
62
+
63
+ it("projects nothing extra from a narrate disposition", () => {
64
+ expect(affordancesOfOutcome(driveAuth)).toEqual([
65
+ { id: "integrations.connectGoogleDrive" },
66
+ ]);
67
+ });
68
+
69
+ it("affordancesOfReason returns the table entry for each workflow type", () => {
70
+ for (const workflowType of ANALYSIS_WORKFLOW_TYPES) {
71
+ const table: Record<string, unknown> =
72
+ WORKFLOW_AFFORDANCES_OF_REASON[workflowType];
73
+ for (const [reason, offer] of Object.entries(table)) {
74
+ expect(affordancesOfReason(workflowType, reason as never)).toEqual(
75
+ offer,
76
+ );
77
+ }
78
+ }
79
+ // Both org-structure types read the one shared table.
80
+ expect(WORKFLOW_AFFORDANCES_OF_REASON["org-structure/hris-connect"]).toBe(
81
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
82
+ );
83
+ expect(WORKFLOW_AFFORDANCES_OF_REASON["org-structure/reanalyze"]).toBe(
84
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
85
+ );
86
+ });
87
+
88
+ it("every projected list is a valid Affordance list", () => {
89
+ for (const outcome of [produced, driveAuth]) {
90
+ expect(
91
+ AffordanceSchema.array().safeParse(affordancesOfOutcome(outcome))
92
+ .success,
93
+ ).toBe(true);
94
+ }
95
+ });
96
+ });
@@ -0,0 +1,167 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { PROACTIVE_EVENT_KIND_IDS } from "../../proactive/kinds";
8
+ import {
9
+ DISCOVER_DEFERRED_ROUTE_OF_REASON,
10
+ DeferredRouteSchema,
11
+ ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON,
12
+ SETTLEMENT_AUDIENCE_POLICIES,
13
+ SETTLEMENT_COVERING_KINDS,
14
+ SYNTHESIZE_DEFERRED_ROUTE_OF_REASON,
15
+ deferredRouteOf,
16
+ toExplainedOutcome,
17
+ type DeferredRoute,
18
+ } from "../deferred-route";
19
+ import { WorkflowOutcomeSchema, type WorkflowOutcome } from "../outcome";
20
+ import {
21
+ DISCOVER_AFFORDANCES_OF_REASON,
22
+ DISCOVER_OUTCOME_KIND_OF_REASON,
23
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
24
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
25
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
26
+ SYNTHESIZE_OUTCOME_KIND_OF_REASON,
27
+ } from "../outcome-reasons";
28
+
29
+ // (kind table, offer table, route table) per outcome family. Built here rather
30
+ // than exported so the rules stay pinned without widening the public surface.
31
+ const TABLES: ReadonlyArray<
32
+ readonly [
33
+ Record<string, string>,
34
+ Record<string, readonly unknown[]>,
35
+ Record<string, DeferredRoute>,
36
+ ]
37
+ > = [
38
+ [
39
+ DISCOVER_OUTCOME_KIND_OF_REASON,
40
+ DISCOVER_AFFORDANCES_OF_REASON,
41
+ DISCOVER_DEFERRED_ROUTE_OF_REASON,
42
+ ],
43
+ [
44
+ SYNTHESIZE_OUTCOME_KIND_OF_REASON,
45
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
46
+ SYNTHESIZE_DEFERRED_ROUTE_OF_REASON,
47
+ ],
48
+ [
49
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
50
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
51
+ ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON,
52
+ ],
53
+ ];
54
+
55
+ const discoverFacts = {
56
+ probesTotal: 12,
57
+ probesDone: 3,
58
+ filesSeen: 0,
59
+ filesShown: 0,
60
+ filesHidden: 0,
61
+ filesDropped: 0,
62
+ coveragePercent: 25,
63
+ };
64
+
65
+ const driveAuth = {
66
+ workflowType: "source-sync/discover",
67
+ executionEpoch: 2,
68
+ kind: "failed",
69
+ reason: "drive_auth",
70
+ facts: discoverFacts,
71
+ disposition: { kind: "narrate" },
72
+ affordances: [{ id: "integrations.connectGoogleDrive" }],
73
+ } as const satisfies WorkflowOutcome;
74
+
75
+ const nothingFound = {
76
+ workflowType: "source-sync/discover",
77
+ executionEpoch: 0,
78
+ kind: "empty",
79
+ reason: "nothing_found",
80
+ facts: discoverFacts,
81
+ disposition: { kind: "narrate" },
82
+ affordances: [],
83
+ } as const satisfies WorkflowOutcome;
84
+
85
+ describe("deferred settlement routes", () => {
86
+ it("every reason of every workflow type has a deferred route", () => {
87
+ for (const [kinds, , routes] of TABLES) {
88
+ expect(Object.keys(routes).sort()).toEqual(Object.keys(kinds).sort());
89
+ for (const route of Object.values(routes)) {
90
+ expect(DeferredRouteSchema.safeParse(route).success).toBe(true);
91
+ }
92
+ }
93
+ });
94
+
95
+ it("an explained route is a fact-free failure that offers at least one affordance", () => {
96
+ for (const [kinds, offers, routes] of TABLES) {
97
+ for (const [reason, route] of Object.entries(routes)) {
98
+ if (route.route !== "explained") continue;
99
+ expect(["failed", "abandoned"]).toContain(kinds[reason]);
100
+ expect(offers[reason]?.length ?? 0).toBeGreaterThan(0);
101
+ }
102
+ }
103
+ });
104
+
105
+ it("a produced, empty or partial ending is never explained", () => {
106
+ for (const [kinds, , routes] of TABLES) {
107
+ for (const [reason, kind] of Object.entries(kinds)) {
108
+ if (!["produced", "empty", "partial"].includes(kind)) continue;
109
+ expect(["notice", "covered"]).toContain(routes[reason]?.route);
110
+ }
111
+ }
112
+ });
113
+
114
+ it("silent is reserved for a person stopping the run", () => {
115
+ for (const [, , routes] of TABLES) {
116
+ for (const [reason, route] of Object.entries(routes)) {
117
+ expect(route.route === "silent").toBe(reason === "stopped_by_user");
118
+ }
119
+ }
120
+ });
121
+
122
+ it("covered names a real proactive kind", () => {
123
+ for (const kind of SETTLEMENT_COVERING_KINDS) {
124
+ expect(PROACTIVE_EVENT_KIND_IDS).toContain(kind);
125
+ }
126
+ });
127
+
128
+ it("a covered route falls back to a notice with an audience", () => {
129
+ let covered = 0;
130
+ for (const [, , routes] of TABLES) {
131
+ for (const route of Object.values(routes)) {
132
+ if (route.route !== "covered") continue;
133
+ covered += 1;
134
+ expect(route.fallback).toBe("notice");
135
+ expect(SETTLEMENT_AUDIENCE_POLICIES).toContain(route.audience);
136
+ }
137
+ }
138
+ expect(covered).toBeGreaterThan(0);
139
+ const by = "sourceSync.review_ready";
140
+ for (const partial of [
141
+ { route: "covered", by },
142
+ { route: "covered", by, fallback: "notice" },
143
+ { route: "covered", by, audience: "creator" },
144
+ ]) {
145
+ expect(DeferredRouteSchema.safeParse(partial).success).toBe(false);
146
+ }
147
+ });
148
+
149
+ it("toExplainedOutcome strips facts and returns null for a route that is not explained", () => {
150
+ expect(deferredRouteOf(driveAuth).route).toBe("explained");
151
+ const explained = toExplainedOutcome(driveAuth);
152
+ expect(explained).toEqual({ ...driveAuth, facts: null });
153
+ expect(explained?.affordances).toEqual(driveAuth.affordances);
154
+ expect(WorkflowOutcomeSchema.safeParse(explained).success).toBe(true);
155
+ expect(toExplainedOutcome(nothingFound)).toBeNull();
156
+ });
157
+
158
+ it("the workflow module imports nothing from proactive", () => {
159
+ const dir = dirname(dirname(fileURLToPath(import.meta.url)));
160
+ const sources = readdirSync(dir).filter((f) => f.endsWith(".ts"));
161
+ expect(sources.length).toBeGreaterThan(0);
162
+ for (const file of sources) {
163
+ const text = readFileSync(join(dir, file), "utf8");
164
+ expect(text).not.toMatch(/from\s+["']\.\.\/proactive/);
165
+ }
166
+ });
167
+ });
@@ -1,14 +1,20 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
+ import {
4
+ MAX_OFFERED_AFFORDANCES,
5
+ OfferedAffordanceSchema,
6
+ } from "../../affordances/affordance";
3
7
  import type { WorkflowType } from "../types";
8
+ import { WorkflowOutcomeSchema, type WorkflowOutcome } from "../outcome";
4
9
  import {
10
+ DISCOVER_AFFORDANCES_OF_REASON,
5
11
  DISCOVER_OUTCOME_KIND_OF_REASON,
12
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
6
13
  ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
14
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
7
15
  SYNTHESIZE_OUTCOME_KIND_OF_REASON,
8
16
  WORKFLOW_OUTCOME_KINDS,
9
- WorkflowOutcomeSchema,
10
- type WorkflowOutcome,
11
- } from "../outcome";
17
+ } from "../outcome-reasons";
12
18
 
13
19
  // Compile-time: every registered workflow type has an outcome member, and the
14
20
  // union names no type that is not registered.
@@ -41,6 +47,7 @@ const empty = {
41
47
  reason: "nothing_found",
42
48
  facts: discoverFacts,
43
49
  disposition: { kind: "narrate" },
50
+ affordances: [],
44
51
  } as const;
45
52
 
46
53
  describe("WorkflowOutcome", () => {
@@ -73,6 +80,7 @@ describe("WorkflowOutcome", () => {
73
80
  kind: "abandoned",
74
81
  reason: "abandoned",
75
82
  facts: null,
83
+ affordances: [{ id: "sourceSync.findFiles" }],
76
84
  };
77
85
  expect(WorkflowOutcomeSchema.safeParse(abandoned).success).toBe(true);
78
86
  });
@@ -127,4 +135,61 @@ describe("WorkflowOutcome", () => {
127
135
  }).success,
128
136
  ).toBe(false);
129
137
  });
138
+
139
+ it("every reason has an explicit offer, and every offer names an offerable affordance", () => {
140
+ for (const [kinds, offers] of [
141
+ [DISCOVER_OUTCOME_KIND_OF_REASON, DISCOVER_AFFORDANCES_OF_REASON],
142
+ [SYNTHESIZE_OUTCOME_KIND_OF_REASON, SYNTHESIZE_AFFORDANCES_OF_REASON],
143
+ [
144
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
145
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
146
+ ],
147
+ ] as const) {
148
+ expect(Object.keys(offers).sort()).toEqual(Object.keys(kinds).sort());
149
+ for (const offer of Object.values(offers)) {
150
+ for (const affordance of offer) {
151
+ expect(OfferedAffordanceSchema.safeParse(affordance).success).toBe(
152
+ true,
153
+ );
154
+ }
155
+ }
156
+ }
157
+ });
158
+
159
+ it("refuses an offer that differs from its reason's table", () => {
160
+ expect(
161
+ WorkflowOutcomeSchema.safeParse({
162
+ ...empty,
163
+ affordances: [{ id: "sourceSync.findFiles" }],
164
+ }).success,
165
+ ).toBe(false);
166
+ });
167
+
168
+ it("refuses a missing affordances key", () => {
169
+ const { affordances: _omitted, ...withoutOffer } = empty;
170
+ expect(WorkflowOutcomeSchema.safeParse(withoutOffer).success).toBe(false);
171
+ });
172
+
173
+ it("refuses review.open inside a frozen offer", () => {
174
+ expect(
175
+ WorkflowOutcomeSchema.safeParse({
176
+ ...empty,
177
+ affordances: [
178
+ {
179
+ id: "review.open",
180
+ args: { requestType: "source-sync/candidates", requestId: "s" },
181
+ },
182
+ ],
183
+ }).success,
184
+ ).toBe(false);
185
+ });
186
+
187
+ it("refuses more than MAX_OFFERED_AFFORDANCES offers", () => {
188
+ const many = Array.from({ length: MAX_OFFERED_AFFORDANCES + 1 }, () => ({
189
+ id: "billing.open",
190
+ }));
191
+ expect(
192
+ WorkflowOutcomeSchema.safeParse({ ...empty, affordances: many }).success,
193
+ ).toBe(false);
194
+ });
130
195
  });