@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.
- package/package.json +2 -2
- package/src/affordances/README.md +65 -0
- package/src/affordances/__tests__/README.md +30 -0
- package/src/affordances/__tests__/affordances.test.ts +251 -0
- package/src/affordances/affordance.ts +124 -0
- package/src/affordances/args.ts +59 -0
- package/src/affordances/availability.ts +78 -0
- package/src/affordances/ids.ts +45 -0
- package/src/affordances/index.ts +39 -0
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +59 -1
- package/src/generated/openapi-routes.ts +1 -0
- package/src/proactive/README.md +26 -4
- package/src/proactive/__tests__/README.md +10 -0
- package/src/proactive/__tests__/chat-templates.test.ts +8 -0
- package/src/proactive/__tests__/vocabulary.test.ts +11 -0
- package/src/proactive/__tests__/workflow-attention-fixtures.ts +49 -0
- package/src/proactive/__tests__/workflow-attention.test.ts +146 -0
- package/src/proactive/composer.ts +8 -0
- package/src/proactive/facts.ts +36 -0
- package/src/proactive/index.ts +2 -0
- package/src/proactive/kinds.ts +29 -0
- package/src/proactive/registry.ts +22 -0
- package/src/proactive/surfaces.ts +2 -0
- package/src/proactive/templates/README.md +9 -1
- package/src/proactive/templates/index.ts +2 -0
- package/src/proactive/templates/workflow-needs-attention.ts +35 -0
- package/src/root-exports/README.md +1 -0
- package/src/root-exports/delivery.ts +69 -1
- package/src/user-notifications/README.md +8 -0
- package/src/user-notifications/kinds.ts +13 -0
- package/src/user-notifications/lifecycle-registry.ts +10 -0
- package/src/workflows/README.md +38 -2
- package/src/workflows/__tests__/README.md +21 -2
- package/src/workflows/__tests__/affordances-of-outcome.test.ts +99 -0
- package/src/workflows/__tests__/deferred-route.test.ts +168 -0
- package/src/workflows/__tests__/outcome.test.ts +66 -0
- package/src/workflows/affordances-of-outcome.ts +65 -0
- package/src/workflows/deferred-route.ts +240 -0
- package/src/workflows/index.ts +34 -0
- 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
|
+
};
|
|
@@ -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.
|
|
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
|
};
|
package/src/workflows/README.md
CHANGED
|
@@ -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)
|
|
54
|
-
|
|
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`
|
|
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
|
});
|