@company-semantics/contracts 90.0.0 → 91.0.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 (41) 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 +59 -1
  12. package/src/generated/openapi-routes.ts +1 -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 +38 -2
  34. package/src/workflows/__tests__/README.md +21 -2
  35. package/src/workflows/__tests__/affordances-of-outcome.test.ts +99 -0
  36. package/src/workflows/__tests__/deferred-route.test.ts +168 -0
  37. package/src/workflows/__tests__/outcome.test.ts +66 -0
  38. package/src/workflows/affordances-of-outcome.ts +65 -0
  39. package/src/workflows/deferred-route.ts +240 -0
  40. package/src/workflows/index.ts +34 -0
  41. package/src/workflows/outcome.ts +114 -1
@@ -31,6 +31,7 @@ import { billingSuspendedComposer } from "./billing-suspended";
31
31
  import { morningBriefComposer } from "./morning-brief";
32
32
  import { orgBecameSharedComposer } from "./org-became-shared";
33
33
  import { sourceSyncReviewReadyComposer } from "./source-sync-review-ready";
34
+ import { workflowNeedsAttentionComposer } from "./workflow-needs-attention";
34
35
 
35
36
  export const PROACTIVE_CHAT_COMPOSERS = {
36
37
  orgBecameShared: orgBecameSharedComposer,
@@ -41,4 +42,5 @@ export const PROACTIVE_CHAT_COMPOSERS = {
41
42
  billingSuspended: billingSuspendedComposer,
42
43
  billingSubscriptionCanceled: billingSubscriptionCanceledComposer,
43
44
  sourceSyncReviewReady: sourceSyncReviewReadyComposer,
45
+ workflowNeedsAttention: workflowNeedsAttentionComposer,
44
46
  } as const satisfies Record<ProactiveChatTemplate, ProactiveChatComposer>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The workflow-needs-attention pushed chat — a background run ended in a
3
+ * failure a person can act on, while nobody was watching.
4
+ *
5
+ * THIS COMPOSER FRAMES; IT DOES NOT NARRATE. The sentence is the backend's
6
+ * deterministic settlement narration (one owner of settlement wording, already
7
+ * under the call-to-action ban), passed in as `narration`. The composer adds a
8
+ * title per workflow type and carries the fact-free outcome so its grounded
9
+ * affordances render as real controls (control ADR affordance-system). It
10
+ * offers no next step in words and no chips: a chip only sends a chat prompt,
11
+ * which is not grounded in a control.
12
+ *
13
+ * NO COUNTS (INV-PROACTIVE-CONTENT): the outcome's facts are stripped and the
14
+ * narration is told from the stripped outcome.
15
+ */
16
+ import type { WorkflowType } from "../../workflows/types";
17
+ import type { ProactiveChatComposer } from "../composer";
18
+
19
+ const TITLE_OF_WORKFLOW_TYPE = {
20
+ "source-sync/discover": "The drive search did not finish",
21
+ "source-sync/synthesize": "The document update did not finish",
22
+ "org-structure/hris-connect": "The org structure analysis did not finish",
23
+ "org-structure/reanalyze": "The org structure analysis did not finish",
24
+ } as const satisfies Record<WorkflowType, string>;
25
+
26
+ export const workflowNeedsAttentionComposer: ProactiveChatComposer<"workflowNeedsAttention"> =
27
+ {
28
+ template: "workflowNeedsAttention",
29
+ compose: (facts) => ({
30
+ title: TITLE_OF_WORKFLOW_TYPE[facts.outcome.outcome.workflowType],
31
+ text: facts.narration,
32
+ replies: [],
33
+ outcome: facts.outcome,
34
+ }),
35
+ };
@@ -48,6 +48,7 @@ documented in its owning domain (`src/org/`, `src/chat/`, `src/comments/`, …).
48
48
  **Internal domains:**
49
49
 
50
50
  - `action-items`
51
+ - `affordances`
51
52
  - `api`
52
53
  - `autotune`
53
54
  - `chat`
@@ -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
  };
@@ -40,6 +40,30 @@ so every producer, validator and renderer agrees on it.
40
40
  adding its outcome member; the union must cover every registered type.
41
41
  - **`unknown` is a safety valve, not a status.** It names an unrecognized
42
42
  terminal transition, never a workflow whose state could not be read.
43
+ - **The offer is a function of reason** (control ADR affordance-system). Every
44
+ outcome carries a REQUIRED `affordances` list — the ordered next steps the
45
+ ending offers — fixed by its reason's per-type `*_AFFORDANCES_OF_REASON`
46
+ table and frozen with the outcome. The tables are total (an empty list is an
47
+ explicit decision) and the schema refuses any other list, a missing key and
48
+ more than `MAX_OFFERED_AFFORDANCES`. Producers read the offer through
49
+ `affordancesOfReason` only.
50
+ - **`review.open` is projected, never stored.** It is derived from a `review`
51
+ disposition; an offer that contains it is refused. `affordancesOfOutcome` is
52
+ the single projection every consumer renders from: the frozen offer, then
53
+ `review.open` when the disposition is `review`.
54
+ - **A deferred settlement's route is a function of reason** (control
55
+ ADR-CTRL-540 phase 2). An ending with no launching conversation takes its
56
+ reason's entry in the total per-type `*_DEFERRED_ROUTE_OF_REASON` table:
57
+ `explained` only for a `failed` or `abandoned` ending that offers at least one
58
+ affordance (told fact-free via `toExplainedOutcome`), `notice` for anything
59
+ whose telling needs counts, `covered` when a domain proactive kind normally
60
+ announces it (always with a `fallback: "notice"` and an audience, because
61
+ the backend verifies the covering occurrence at route time), and `silent`
62
+ exactly for `stopped_by_user`. A `produced`, `empty` or `partial` ending is
63
+ never explained.
64
+ - **Workflows never imports proactive.** The covering kinds are literal
65
+ strings pinned against `PROACTIVE_EVENT_KIND_IDS` by a test; the dependency
66
+ runs proactive → workflows only.
43
67
 
44
68
  ## Public API
45
69
 
@@ -47,8 +71,20 @@ so every producer, validator and renderer agrees on it.
47
71
  - `WorkflowOutcomeSchema` / `WorkflowOutcome` and the per-type member schemas,
48
72
  reason tables, fact schemas and `WorkflowOutcomeDispositionSchema`
49
73
  (`outcome.ts`).
74
+ - `DISCOVER_AFFORDANCES_OF_REASON`, `SYNTHESIZE_AFFORDANCES_OF_REASON`,
75
+ `ORG_STRUCTURE_AFFORDANCES_OF_REASON` (`outcome.ts`).
76
+ - `WORKFLOW_AFFORDANCES_OF_REASON`, `affordancesOfReason`,
77
+ `affordancesOfOutcome` (`affordances-of-outcome.ts`).
78
+ - `SETTLEMENT_AUDIENCE_POLICIES`, `SETTLEMENT_COVERING_KINDS`,
79
+ `SETTLEMENT_SILENT_REASONS`, `DEFERRED_ROUTES`, `DeferredRouteSchema` /
80
+ `DeferredRoute`, the `*_DEFERRED_ROUTE_OF_REASON` tables,
81
+ `WORKFLOW_DEFERRED_ROUTE_OF_REASON`, `ExplainedWorkflowOutcome` and its
82
+ `Explained*Reason` types, `deferredRouteOf`, `toExplainedOutcome`
83
+ (`deferred-route.ts`).
50
84
 
51
85
  ## Dependencies
52
86
 
53
- `zod` (outcome schemas) and `../input-requests/types` (the review
54
- disposition's request type). Otherwise pure vocabulary data and one guard.
87
+ `zod` (outcome schemas), `../input-requests/types` (the review disposition's
88
+ request type) and `../affordances/affordance` (the offered-affordance schema,
89
+ its sanity bound and `sameAffordances`). Otherwise pure vocabulary data, one
90
+ 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,99 @@
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 {
10
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
11
+ WorkflowOutcomeSchema,
12
+ type WorkflowOutcome,
13
+ } from "../outcome";
14
+ import { ANALYSIS_WORKFLOW_TYPES } from "../types";
15
+
16
+ const review = {
17
+ kind: "review",
18
+ requestType: "source-sync/candidates",
19
+ requestId: "set-1",
20
+ } as const;
21
+
22
+ const produced: WorkflowOutcome = {
23
+ workflowType: "source-sync/discover",
24
+ executionEpoch: 0,
25
+ kind: "produced",
26
+ reason: "candidates_found",
27
+ facts: null,
28
+ disposition: review,
29
+ affordances: [],
30
+ };
31
+
32
+ const partial: WorkflowOutcome = {
33
+ ...produced,
34
+ kind: "partial",
35
+ reason: "budget_exhausted",
36
+ };
37
+
38
+ const driveAuth: WorkflowOutcome = {
39
+ ...produced,
40
+ kind: "failed",
41
+ reason: "drive_auth",
42
+ disposition: { kind: "narrate" },
43
+ affordances: [{ id: "integrations.connectGoogleDrive" }],
44
+ };
45
+
46
+ const REVIEW_OPEN = {
47
+ id: "review.open",
48
+ args: { requestType: "source-sync/candidates", requestId: "set-1" },
49
+ };
50
+
51
+ describe("affordancesOfOutcome", () => {
52
+ it("fixtures are valid outcomes", () => {
53
+ for (const outcome of [produced, partial, driveAuth]) {
54
+ expect(WorkflowOutcomeSchema.safeParse(outcome).success).toBe(true);
55
+ }
56
+ });
57
+
58
+ it("projects review.open from a review disposition, after the frozen offer", () => {
59
+ expect(affordancesOfOutcome(produced)).toEqual([REVIEW_OPEN]);
60
+ expect(affordancesOfOutcome(partial)).toEqual([
61
+ ...partial.affordances,
62
+ REVIEW_OPEN,
63
+ ]);
64
+ });
65
+
66
+ it("projects nothing extra from a narrate disposition", () => {
67
+ expect(affordancesOfOutcome(driveAuth)).toEqual([
68
+ { id: "integrations.connectGoogleDrive" },
69
+ ]);
70
+ });
71
+
72
+ it("affordancesOfReason returns the table entry for each workflow type", () => {
73
+ for (const workflowType of ANALYSIS_WORKFLOW_TYPES) {
74
+ const table: Record<string, unknown> =
75
+ WORKFLOW_AFFORDANCES_OF_REASON[workflowType];
76
+ for (const [reason, offer] of Object.entries(table)) {
77
+ expect(affordancesOfReason(workflowType, reason as never)).toEqual(
78
+ offer,
79
+ );
80
+ }
81
+ }
82
+ // Both org-structure types read the one shared table.
83
+ expect(WORKFLOW_AFFORDANCES_OF_REASON["org-structure/hris-connect"]).toBe(
84
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
85
+ );
86
+ expect(WORKFLOW_AFFORDANCES_OF_REASON["org-structure/reanalyze"]).toBe(
87
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
88
+ );
89
+ });
90
+
91
+ it("every projected list is a valid Affordance list", () => {
92
+ for (const outcome of [produced, driveAuth]) {
93
+ expect(
94
+ AffordanceSchema.array().safeParse(affordancesOfOutcome(outcome))
95
+ .success,
96
+ ).toBe(true);
97
+ }
98
+ });
99
+ });
@@ -0,0 +1,168 @@
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 {
20
+ DISCOVER_AFFORDANCES_OF_REASON,
21
+ DISCOVER_OUTCOME_KIND_OF_REASON,
22
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
23
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
24
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
25
+ SYNTHESIZE_OUTCOME_KIND_OF_REASON,
26
+ WorkflowOutcomeSchema,
27
+ type WorkflowOutcome,
28
+ } from "../outcome";
29
+
30
+ // (kind table, offer table, route table) per outcome family. Built here rather
31
+ // than exported so the rules stay pinned without widening the public surface.
32
+ const TABLES: ReadonlyArray<
33
+ readonly [
34
+ Record<string, string>,
35
+ Record<string, readonly unknown[]>,
36
+ Record<string, DeferredRoute>,
37
+ ]
38
+ > = [
39
+ [
40
+ DISCOVER_OUTCOME_KIND_OF_REASON,
41
+ DISCOVER_AFFORDANCES_OF_REASON,
42
+ DISCOVER_DEFERRED_ROUTE_OF_REASON,
43
+ ],
44
+ [
45
+ SYNTHESIZE_OUTCOME_KIND_OF_REASON,
46
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
47
+ SYNTHESIZE_DEFERRED_ROUTE_OF_REASON,
48
+ ],
49
+ [
50
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
51
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
52
+ ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON,
53
+ ],
54
+ ];
55
+
56
+ const discoverFacts = {
57
+ probesTotal: 12,
58
+ probesDone: 3,
59
+ filesSeen: 0,
60
+ filesShown: 0,
61
+ filesHidden: 0,
62
+ filesDropped: 0,
63
+ coveragePercent: 25,
64
+ };
65
+
66
+ const driveAuth = {
67
+ workflowType: "source-sync/discover",
68
+ executionEpoch: 2,
69
+ kind: "failed",
70
+ reason: "drive_auth",
71
+ facts: discoverFacts,
72
+ disposition: { kind: "narrate" },
73
+ affordances: [{ id: "integrations.connectGoogleDrive" }],
74
+ } as const satisfies WorkflowOutcome;
75
+
76
+ const nothingFound = {
77
+ workflowType: "source-sync/discover",
78
+ executionEpoch: 0,
79
+ kind: "empty",
80
+ reason: "nothing_found",
81
+ facts: discoverFacts,
82
+ disposition: { kind: "narrate" },
83
+ affordances: [],
84
+ } as const satisfies WorkflowOutcome;
85
+
86
+ describe("deferred settlement routes", () => {
87
+ it("every reason of every workflow type has a deferred route", () => {
88
+ for (const [kinds, , routes] of TABLES) {
89
+ expect(Object.keys(routes).sort()).toEqual(Object.keys(kinds).sort());
90
+ for (const route of Object.values(routes)) {
91
+ expect(DeferredRouteSchema.safeParse(route).success).toBe(true);
92
+ }
93
+ }
94
+ });
95
+
96
+ it("an explained route is a fact-free failure that offers at least one affordance", () => {
97
+ for (const [kinds, offers, routes] of TABLES) {
98
+ for (const [reason, route] of Object.entries(routes)) {
99
+ if (route.route !== "explained") continue;
100
+ expect(["failed", "abandoned"]).toContain(kinds[reason]);
101
+ expect(offers[reason]?.length ?? 0).toBeGreaterThan(0);
102
+ }
103
+ }
104
+ });
105
+
106
+ it("a produced, empty or partial ending is never explained", () => {
107
+ for (const [kinds, , routes] of TABLES) {
108
+ for (const [reason, kind] of Object.entries(kinds)) {
109
+ if (!["produced", "empty", "partial"].includes(kind)) continue;
110
+ expect(["notice", "covered"]).toContain(routes[reason]?.route);
111
+ }
112
+ }
113
+ });
114
+
115
+ it("silent is reserved for a person stopping the run", () => {
116
+ for (const [, , routes] of TABLES) {
117
+ for (const [reason, route] of Object.entries(routes)) {
118
+ expect(route.route === "silent").toBe(reason === "stopped_by_user");
119
+ }
120
+ }
121
+ });
122
+
123
+ it("covered names a real proactive kind", () => {
124
+ for (const kind of SETTLEMENT_COVERING_KINDS) {
125
+ expect(PROACTIVE_EVENT_KIND_IDS).toContain(kind);
126
+ }
127
+ });
128
+
129
+ it("a covered route falls back to a notice with an audience", () => {
130
+ let covered = 0;
131
+ for (const [, , routes] of TABLES) {
132
+ for (const route of Object.values(routes)) {
133
+ if (route.route !== "covered") continue;
134
+ covered += 1;
135
+ expect(route.fallback).toBe("notice");
136
+ expect(SETTLEMENT_AUDIENCE_POLICIES).toContain(route.audience);
137
+ }
138
+ }
139
+ expect(covered).toBeGreaterThan(0);
140
+ const by = "sourceSync.review_ready";
141
+ for (const partial of [
142
+ { route: "covered", by },
143
+ { route: "covered", by, fallback: "notice" },
144
+ { route: "covered", by, audience: "creator" },
145
+ ]) {
146
+ expect(DeferredRouteSchema.safeParse(partial).success).toBe(false);
147
+ }
148
+ });
149
+
150
+ it("toExplainedOutcome strips facts and returns null for a route that is not explained", () => {
151
+ expect(deferredRouteOf(driveAuth).route).toBe("explained");
152
+ const explained = toExplainedOutcome(driveAuth);
153
+ expect(explained).toEqual({ ...driveAuth, facts: null });
154
+ expect(explained?.affordances).toEqual(driveAuth.affordances);
155
+ expect(WorkflowOutcomeSchema.safeParse(explained).success).toBe(true);
156
+ expect(toExplainedOutcome(nothingFound)).toBeNull();
157
+ });
158
+
159
+ it("the workflow module imports nothing from proactive", () => {
160
+ const dir = dirname(dirname(fileURLToPath(import.meta.url)));
161
+ const sources = readdirSync(dir).filter((f) => f.endsWith(".ts"));
162
+ expect(sources.length).toBeGreaterThan(0);
163
+ for (const file of sources) {
164
+ const text = readFileSync(join(dir, file), "utf8");
165
+ expect(text).not.toMatch(/from\s+["']\.\.\/proactive/);
166
+ }
167
+ });
168
+ });
@@ -1,9 +1,16 @@
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";
4
8
  import {
9
+ DISCOVER_AFFORDANCES_OF_REASON,
5
10
  DISCOVER_OUTCOME_KIND_OF_REASON,
11
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
6
12
  ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
13
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
7
14
  SYNTHESIZE_OUTCOME_KIND_OF_REASON,
8
15
  WORKFLOW_OUTCOME_KINDS,
9
16
  WorkflowOutcomeSchema,
@@ -41,6 +48,7 @@ const empty = {
41
48
  reason: "nothing_found",
42
49
  facts: discoverFacts,
43
50
  disposition: { kind: "narrate" },
51
+ affordances: [],
44
52
  } as const;
45
53
 
46
54
  describe("WorkflowOutcome", () => {
@@ -73,6 +81,7 @@ describe("WorkflowOutcome", () => {
73
81
  kind: "abandoned",
74
82
  reason: "abandoned",
75
83
  facts: null,
84
+ affordances: [{ id: "sourceSync.findFiles" }],
76
85
  };
77
86
  expect(WorkflowOutcomeSchema.safeParse(abandoned).success).toBe(true);
78
87
  });
@@ -127,4 +136,61 @@ describe("WorkflowOutcome", () => {
127
136
  }).success,
128
137
  ).toBe(false);
129
138
  });
139
+
140
+ it("every reason has an explicit offer, and every offer names an offerable affordance", () => {
141
+ for (const [kinds, offers] of [
142
+ [DISCOVER_OUTCOME_KIND_OF_REASON, DISCOVER_AFFORDANCES_OF_REASON],
143
+ [SYNTHESIZE_OUTCOME_KIND_OF_REASON, SYNTHESIZE_AFFORDANCES_OF_REASON],
144
+ [
145
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
146
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
147
+ ],
148
+ ] as const) {
149
+ expect(Object.keys(offers).sort()).toEqual(Object.keys(kinds).sort());
150
+ for (const offer of Object.values(offers)) {
151
+ for (const affordance of offer) {
152
+ expect(OfferedAffordanceSchema.safeParse(affordance).success).toBe(
153
+ true,
154
+ );
155
+ }
156
+ }
157
+ }
158
+ });
159
+
160
+ it("refuses an offer that differs from its reason's table", () => {
161
+ expect(
162
+ WorkflowOutcomeSchema.safeParse({
163
+ ...empty,
164
+ affordances: [{ id: "sourceSync.findFiles" }],
165
+ }).success,
166
+ ).toBe(false);
167
+ });
168
+
169
+ it("refuses a missing affordances key", () => {
170
+ const { affordances: _omitted, ...withoutOffer } = empty;
171
+ expect(WorkflowOutcomeSchema.safeParse(withoutOffer).success).toBe(false);
172
+ });
173
+
174
+ it("refuses review.open inside a frozen offer", () => {
175
+ expect(
176
+ WorkflowOutcomeSchema.safeParse({
177
+ ...empty,
178
+ affordances: [
179
+ {
180
+ id: "review.open",
181
+ args: { requestType: "source-sync/candidates", requestId: "s" },
182
+ },
183
+ ],
184
+ }).success,
185
+ ).toBe(false);
186
+ });
187
+
188
+ it("refuses more than MAX_OFFERED_AFFORDANCES offers", () => {
189
+ const many = Array.from({ length: MAX_OFFERED_AFFORDANCES + 1 }, () => ({
190
+ id: "billing.open",
191
+ }));
192
+ expect(
193
+ WorkflowOutcomeSchema.safeParse({ ...empty, affordances: many }).success,
194
+ ).toBe(false);
195
+ });
130
196
  });