@superblocksteam/shared 0.9602.0 → 0.9603.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 (85) hide show
  1. package/dist/types/codeSecurity/index.d.ts +38 -0
  2. package/dist/types/codeSecurity/index.d.ts.map +1 -0
  3. package/dist/types/codeSecurity/index.js +10 -0
  4. package/dist/types/codeSecurity/index.js.map +1 -0
  5. package/dist/types/deployment/deployQueueStatus.d.ts +31 -0
  6. package/dist/types/deployment/deployQueueStatus.d.ts.map +1 -1
  7. package/dist/types/deployment/index.d.ts +1 -1
  8. package/dist/types/deployment/index.d.ts.map +1 -1
  9. package/dist/types/deployment/index.js.map +1 -1
  10. package/dist/types/index.d.ts +8 -5
  11. package/dist/types/index.d.ts.map +1 -1
  12. package/dist/types/index.js +8 -5
  13. package/dist/types/index.js.map +1 -1
  14. package/dist/types/pagination/index.d.ts +7 -0
  15. package/dist/types/pagination/index.d.ts.map +1 -0
  16. package/dist/types/pagination/index.js +3 -0
  17. package/dist/types/pagination/index.js.map +1 -0
  18. package/dist/types/publishPipeline/index.d.ts +78 -0
  19. package/dist/types/publishPipeline/index.d.ts.map +1 -0
  20. package/dist/types/publishPipeline/index.js +56 -0
  21. package/dist/types/publishPipeline/index.js.map +1 -0
  22. package/dist/types/publishPipeline/plan.d.ts +18 -0
  23. package/dist/types/publishPipeline/plan.d.ts.map +1 -0
  24. package/dist/types/publishPipeline/plan.js +110 -0
  25. package/dist/types/publishPipeline/plan.js.map +1 -0
  26. package/dist/types/publishPipeline/plan.test.d.ts +2 -0
  27. package/dist/types/publishPipeline/plan.test.d.ts.map +1 -0
  28. package/dist/types/publishPipeline/plan.test.js +181 -0
  29. package/dist/types/publishPipeline/plan.test.js.map +1 -0
  30. package/dist/types/publishPipeline/stepIds.test.d.ts +2 -0
  31. package/dist/types/publishPipeline/stepIds.test.d.ts.map +1 -0
  32. package/dist/types/publishPipeline/stepIds.test.js +34 -0
  33. package/dist/types/publishPipeline/stepIds.test.js.map +1 -0
  34. package/dist/types/rbac/index.d.ts +2 -2
  35. package/dist/types/rbac/index.d.ts.map +1 -1
  36. package/dist/types/rbac/index.js +2 -2
  37. package/dist/types/rbac/index.js.map +1 -1
  38. package/dist-esm/types/codeSecurity/index.d.ts +38 -0
  39. package/dist-esm/types/codeSecurity/index.d.ts.map +1 -0
  40. package/dist-esm/types/codeSecurity/index.js +7 -0
  41. package/dist-esm/types/codeSecurity/index.js.map +1 -0
  42. package/dist-esm/types/deployment/deployQueueStatus.d.ts +31 -0
  43. package/dist-esm/types/deployment/deployQueueStatus.d.ts.map +1 -1
  44. package/dist-esm/types/deployment/index.d.ts +1 -1
  45. package/dist-esm/types/deployment/index.d.ts.map +1 -1
  46. package/dist-esm/types/deployment/index.js.map +1 -1
  47. package/dist-esm/types/index.d.ts +8 -5
  48. package/dist-esm/types/index.d.ts.map +1 -1
  49. package/dist-esm/types/index.js +8 -5
  50. package/dist-esm/types/index.js.map +1 -1
  51. package/dist-esm/types/pagination/index.d.ts +7 -0
  52. package/dist-esm/types/pagination/index.d.ts.map +1 -0
  53. package/dist-esm/types/pagination/index.js +2 -0
  54. package/dist-esm/types/pagination/index.js.map +1 -0
  55. package/dist-esm/types/publishPipeline/index.d.ts +78 -0
  56. package/dist-esm/types/publishPipeline/index.d.ts.map +1 -0
  57. package/dist-esm/types/publishPipeline/index.js +39 -0
  58. package/dist-esm/types/publishPipeline/index.js.map +1 -0
  59. package/dist-esm/types/publishPipeline/plan.d.ts +18 -0
  60. package/dist-esm/types/publishPipeline/plan.d.ts.map +1 -0
  61. package/dist-esm/types/publishPipeline/plan.js +104 -0
  62. package/dist-esm/types/publishPipeline/plan.js.map +1 -0
  63. package/dist-esm/types/publishPipeline/plan.test.d.ts +2 -0
  64. package/dist-esm/types/publishPipeline/plan.test.d.ts.map +1 -0
  65. package/dist-esm/types/publishPipeline/plan.test.js +179 -0
  66. package/dist-esm/types/publishPipeline/plan.test.js.map +1 -0
  67. package/dist-esm/types/publishPipeline/stepIds.test.d.ts +2 -0
  68. package/dist-esm/types/publishPipeline/stepIds.test.d.ts.map +1 -0
  69. package/dist-esm/types/publishPipeline/stepIds.test.js +32 -0
  70. package/dist-esm/types/publishPipeline/stepIds.test.js.map +1 -0
  71. package/dist-esm/types/rbac/index.d.ts +2 -2
  72. package/dist-esm/types/rbac/index.d.ts.map +1 -1
  73. package/dist-esm/types/rbac/index.js +2 -2
  74. package/dist-esm/types/rbac/index.js.map +1 -1
  75. package/package.json +1 -1
  76. package/src/types/codeSecurity/index.ts +51 -0
  77. package/src/types/deployment/deployQueueStatus.ts +35 -0
  78. package/src/types/deployment/index.ts +8 -1
  79. package/src/types/index.ts +8 -5
  80. package/src/types/pagination/index.ts +7 -0
  81. package/src/types/publishPipeline/index.ts +104 -0
  82. package/src/types/publishPipeline/plan.test.ts +200 -0
  83. package/src/types/publishPipeline/plan.ts +114 -0
  84. package/src/types/publishPipeline/stepIds.test.ts +35 -0
  85. package/src/types/rbac/index.ts +2 -2
@@ -0,0 +1,51 @@
1
+ // Wire contract for the code-security dependency-scan endpoints, shared by the
2
+ // server handlers and the UI so the two can't drift. The taxonomy below is
3
+ // mirrored by the PackageVulnerability DB entity (its columns import these).
4
+
5
+ export type Ecosystem = 'npm';
6
+ export type VulnerabilitySource = 'osv';
7
+ export type AdvisoryKind = 'vulnerability' | 'malware';
8
+
9
+ // Qualitative bands, ascending, then `unknown` (no scorable CVSS vector and no
10
+ // qualitative rating). Doubles as the runtime allowlist for untrusted severity input.
11
+ export const SEVERITY_LEVELS = ['none', 'low', 'medium', 'high', 'critical', 'unknown'] as const;
12
+ export type SeverityLevel = (typeof SEVERITY_LEVELS)[number];
13
+
14
+ // needs_review: version couldn't be pinned, so a "clear" verdict would be unearned.
15
+ export type DependencyScanStatus = 'clear' | 'needs_review' | 'unresolved';
16
+
17
+ export interface AdvisoryDetail {
18
+ advisoryId: string;
19
+ source: VulnerabilitySource;
20
+ kind: AdvisoryKind;
21
+ severity: SeverityLevel;
22
+ summary: string | null;
23
+ packageName: string;
24
+ currentVersion: string;
25
+ fixedVersion: string | null;
26
+ lowConfidence: boolean;
27
+ }
28
+
29
+ export interface PerAppVuln {
30
+ appId: string;
31
+ name: string;
32
+ owner: string | null;
33
+ published: boolean;
34
+ visibility: 'private' | 'public';
35
+ status: DependencyScanStatus;
36
+ totalCount: number;
37
+ topSeverity: SeverityLevel | null;
38
+ severityCounts: Partial<Record<SeverityLevel, number>>;
39
+ advisories: AdvisoryDetail[];
40
+ lowConfidence: boolean;
41
+ // Caller may undeploy this app (APPLICATIONS:DEPLOY). Set per-request on the
42
+ // list handler — not part of the org scan cache.
43
+ canDeploy: boolean;
44
+ }
45
+
46
+ export interface DependencyScanSummary {
47
+ overallSeverityCounts: Partial<Record<SeverityLevel, number>>;
48
+ publishedPublicWithVulns: number;
49
+ publishedWithVulns: number;
50
+ totalProjects: number;
51
+ }
@@ -9,3 +9,38 @@ export enum DeployQueueStatus {
9
9
 
10
10
  /** Status returned by the deploy-status polling endpoint. "none" means no queue entry exists. */
11
11
  export type DeployStatusResponseStatus = 'none' | DeployQueueStatus;
12
+
13
+ /**
14
+ * Identifies a sub-stage of the native-DB publish lifecycle that the UI
15
+ * surfaces independently of the overall deploy queue status. `provisioning`
16
+ * tracks `ensure_database` work; `migrating` tracks `migrate_schema` work.
17
+ */
18
+ export type LifecycleStageKind = 'provisioning' | 'migrating';
19
+
20
+ /**
21
+ * Aggregated state of a lifecycle stage across all prod bindings for a
22
+ * commit. Worst-case wins (any failed → failed, any in_progress →
23
+ * in_progress, all complete → complete, otherwise pending).
24
+ */
25
+ export type LifecycleStageState = 'pending' | 'in_progress' | 'complete' | 'failed';
26
+
27
+ export interface LifecycleStageProgress {
28
+ kind: LifecycleStageKind;
29
+ state: LifecycleStageState;
30
+ errorCode?: string;
31
+ errorMessage?: string;
32
+ }
33
+
34
+ /**
35
+ * Native-DB lifecycle progress for a publish. Sent alongside the deploy
36
+ * queue status so the UI can render provisioning and migration steps.
37
+ * Omitted from responses when no native-DB requirements exist for the commit.
38
+ *
39
+ * `stages` lists only lifecycle work queued for THIS commit — republishes
40
+ * that reuse an already-provisioned / already-migrated DB may return
41
+ * `hasNativeDb: true` with an empty `stages` array.
42
+ */
43
+ export interface DeployLifecycleProgress {
44
+ hasNativeDb: boolean;
45
+ stages: LifecycleStageProgress[];
46
+ }
@@ -1,4 +1,11 @@
1
- export { DeployQueueStatus, type DeployStatusResponseStatus } from './deployQueueStatus.js';
1
+ export {
2
+ DeployQueueStatus,
3
+ type DeployLifecycleProgress,
4
+ type DeployStatusResponseStatus,
5
+ type LifecycleStageKind,
6
+ type LifecycleStageProgress,
7
+ type LifecycleStageState
8
+ } from './deployQueueStatus.js';
2
9
 
3
10
  export interface DeploymentDto {
4
11
  commitId: string | null;
@@ -6,6 +6,7 @@ export * from './audit/index.js';
6
6
  export * from './auth/index.js';
7
7
  export * from './billing/index.js';
8
8
  export * from './ai/index.js';
9
+ export * from './codeSecurity/index.js';
9
10
  export * from './commit/index.js';
10
11
  export * from './branch/index.js';
11
12
  export * from './common/index.js';
@@ -22,22 +23,24 @@ export * from './lock/index.js';
22
23
  export * from './observability/index.js';
23
24
  export * from './organization/index.js';
24
25
  export * from './page/index.js';
26
+ export * from './pagination/index.js';
25
27
  export * from './policyGate/index.js';
26
28
  export * from './plugin/index.js';
27
29
  export * from './profile/index.js';
30
+ export * from './publishPipeline/index.js';
28
31
  export * from './rbac/index.js';
32
+ export * from './remoteCommit/index.js';
33
+ export * from './repoConnection/index.js';
34
+ export * from './repository/index.js';
29
35
  export * from './response/index.js';
30
36
  export * from './reviewPolicy/index.js';
31
- export * from './remoteCommit/index.js';
32
37
  export * from './scim/index.js';
33
38
  export * from './signingKeyRotationJob/index.js';
39
+ export * from './template/index.js';
40
+ export * from './uiBlock/index.js';
34
41
  export * from './user/index.js';
35
42
  export * from './userToken/index.js';
36
43
  export * from './version/index.js';
37
- export * from './repoConnection/index.js';
38
- export * from './repository/index.js';
39
44
  export * from './git/index.js';
40
- export * from './template/index.js';
41
45
  export * from './metadata/index.js';
42
46
  export * from './resource/index.js';
43
- export * from './uiBlock/index.js';
@@ -0,0 +1,7 @@
1
+ // Offset-pagination envelope. `total` is the filtered count before slicing.
2
+ export interface Pagination<T> {
3
+ items: T[];
4
+ total: number;
5
+ limit: number;
6
+ offset: number;
7
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Shared contract for the publish pipeline: one ordered list of steps the
3
+ * server computes for the UI status poll (GET publish-pipeline). Domains
4
+ * project into these types; rich payloads ride in opaque `detail` and are only
5
+ * read by that domain's UI renderer.
6
+ *
7
+ * Status-only — it does not start execution and does not gate deploy.
8
+ * Mutations (publish-readiness/start, POST deployments) start work and keep
9
+ * their own authoritative checks (build, policy, database lifecycle reconcile);
10
+ * this plan only reflects progress for the rail.
11
+ */
12
+
13
+ /** Which domain owns a step. Extend as new gates are added. */
14
+ export type PublishStepKind = 'build' | 'database_migration' | 'database_provisioning' | 'policy_gates' | 'publish';
15
+
16
+ /**
17
+ * Domain-agnostic status. Ordered here by roll-up precedence (worst first).
18
+ * `blocked` means this step's own check failed (remediation). Waiting on an
19
+ * earlier step is `pending` with optional `blockedBy`.
20
+ * `skipped` is a visible N/A row (e.g. Build when the app has no native-git remote).
21
+ */
22
+ export type PublishStepStatus =
23
+ | 'failed'
24
+ | 'blocked'
25
+ | 'action_required'
26
+ | 'stale'
27
+ | 'in_progress'
28
+ | 'pending'
29
+ | 'advisory'
30
+ | 'passed'
31
+ | 'skipped';
32
+
33
+ export type BuildPublishStepDetail = {
34
+ buildStatus: string | null;
35
+ error?: string;
36
+ };
37
+
38
+ export type PublishDeployStepDetail = {
39
+ error?: string;
40
+ isLive?: boolean;
41
+ queueStatus: string;
42
+ };
43
+
44
+ export type PublishStep = {
45
+ /** Stable unique id in the plan (a PublishStepId; typed as string so unknown ids still flow through). */
46
+ id: string;
47
+ kind: PublishStepKind;
48
+ status: PublishStepStatus;
49
+ /** Human label for the step row (server-derived). */
50
+ displayName: string;
51
+ /** Static help text under the display name (not an error). */
52
+ description?: string;
53
+ /** Short subtitle / one-line error. */
54
+ summary?: string;
55
+ /** When status is pending and waiting on earlier steps. */
56
+ blockedBy?: PublishStepKind[];
57
+ /** Machine code when blocked/failed (e.g. POLICY_GATE_BLOCKED). */
58
+ blockingReason?: string;
59
+ /** Opaque domain payload; generic pipeline must not read this. */
60
+ detail?: unknown;
61
+ };
62
+
63
+ export type PublishPlanTarget = {
64
+ applicationId: string;
65
+ commitId: string;
66
+ };
67
+
68
+ export type PublishPlan = {
69
+ status: PublishStepStatus;
70
+ steps: PublishStep[];
71
+ target: PublishPlanTarget;
72
+ };
73
+
74
+ /**
75
+ * Single source of truth for publish-plan step ids. These are wire values: the
76
+ * server emits them on each step and the UI matches on them to wire custom
77
+ * detail panels. Keys are lowerCamelCase; values are the lowercase snake_case
78
+ * wire literals. Add new steps here and they become available to both sides.
79
+ */
80
+ export const PublishStepId = {
81
+ build: 'build',
82
+ databaseMigration: 'database_migration',
83
+ databaseProvisioning: 'database_provisioning',
84
+ policyGatesBeforeBuild: 'policy_gates:before_build',
85
+ policyGatesBeforeDeploy: 'policy_gates:before_deploy',
86
+ publish: 'publish'
87
+ } as const;
88
+
89
+ export type PublishStepId = (typeof PublishStepId)[keyof typeof PublishStepId];
90
+
91
+ /**
92
+ * Fixed spine order. Composer filters to steps that were emitted; order is always
93
+ * this sequence. Policy may emit before_build and/or before_deploy.
94
+ */
95
+ export const PUBLISH_STEP_SPINE: readonly PublishStepId[] = [
96
+ PublishStepId.policyGatesBeforeBuild,
97
+ PublishStepId.build,
98
+ PublishStepId.policyGatesBeforeDeploy,
99
+ PublishStepId.databaseProvisioning,
100
+ PublishStepId.databaseMigration,
101
+ PublishStepId.publish
102
+ ] as const;
103
+
104
+ export * from './plan.js';
@@ -0,0 +1,200 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import type { PublishStep, PublishStepKind, PublishStepStatus } from './index.js';
4
+ import { applyBlockedByEligibility, buildPublishPlan, orderPublishSteps, rollUpPublishPlan } from './plan.js';
5
+
6
+ const TARGET = { applicationId: 'app-1', commitId: 'commit-1' };
7
+
8
+ function step(overrides: Partial<PublishStep> & Pick<PublishStep, 'id'>): PublishStep {
9
+ return {
10
+ displayName: overrides.displayName ?? overrides.id,
11
+ kind: overrides.kind ?? 'build',
12
+ status: overrides.status ?? 'passed',
13
+ ...overrides
14
+ };
15
+ }
16
+
17
+ describe('rollUpPublishPlan', () => {
18
+ test('an empty plan is passed', () => {
19
+ expect(rollUpPublishPlan([])).toBe('passed');
20
+ });
21
+
22
+ test('the worst status wins by precedence', () => {
23
+ expect(
24
+ rollUpPublishPlan([step({ id: 'a', status: 'passed' }), step({ id: 'b', status: 'advisory' }), step({ id: 'c', status: 'blocked' })])
25
+ ).toBe('blocked');
26
+ expect(rollUpPublishPlan([step({ id: 'a', status: 'in_progress' }), step({ id: 'b', status: 'failed' })])).toBe('failed');
27
+ expect(rollUpPublishPlan([step({ id: 'a', status: 'advisory' }), step({ id: 'b', status: 'passed' })])).toBe('advisory');
28
+ });
29
+
30
+ test('skipped never masks a real status', () => {
31
+ expect(rollUpPublishPlan([step({ id: 'a', status: 'skipped' }), step({ id: 'b', status: 'passed' })])).toBe('passed');
32
+ expect(rollUpPublishPlan([step({ id: 'a', status: 'skipped' })])).toBe('skipped');
33
+ });
34
+
35
+ test('precedence order is exhaustive and worst-first', () => {
36
+ const precedence: PublishStepStatus[] = [
37
+ 'failed',
38
+ 'blocked',
39
+ 'action_required',
40
+ 'stale',
41
+ 'in_progress',
42
+ 'pending',
43
+ 'advisory',
44
+ 'passed',
45
+ 'skipped'
46
+ ];
47
+ for (let i = 0; i < precedence.length; i += 1) {
48
+ const worse = precedence[i];
49
+ const better = precedence[i + 1];
50
+ const steps = better
51
+ ? [step({ id: 'better', status: better }), step({ id: 'worse', status: worse })]
52
+ : [step({ id: 'only', status: worse })];
53
+ expect(rollUpPublishPlan(steps)).toBe(worse);
54
+ }
55
+ });
56
+ });
57
+
58
+ describe('orderPublishSteps', () => {
59
+ test('orders steps by the publish spine regardless of input order', () => {
60
+ const ordered = orderPublishSteps([
61
+ step({ id: 'publish', kind: 'publish' }),
62
+ step({ id: 'policy_gates:before_build', kind: 'policy_gates' }),
63
+ step({ id: 'build', kind: 'build' }),
64
+ step({ id: 'database_provisioning', kind: 'database_provisioning' }),
65
+ step({ id: 'policy_gates:before_deploy', kind: 'policy_gates' }),
66
+ step({ id: 'database_migration', kind: 'database_migration' })
67
+ ]);
68
+ expect(ordered.map((s) => s.id)).toEqual([
69
+ 'policy_gates:before_build',
70
+ 'build',
71
+ 'policy_gates:before_deploy',
72
+ 'database_provisioning',
73
+ 'database_migration',
74
+ 'publish'
75
+ ]);
76
+ });
77
+
78
+ test('omits ids that are not present', () => {
79
+ const ordered = orderPublishSteps([step({ id: 'build', kind: 'build' }), step({ id: 'publish', kind: 'publish' })]);
80
+ expect(ordered.map((s) => s.id)).toEqual(['build', 'publish']);
81
+ });
82
+ });
83
+
84
+ describe('applyBlockedByEligibility', () => {
85
+ test('marks later pending steps as blockedBy earlier unclear steps', () => {
86
+ const steps = applyBlockedByEligibility([
87
+ step({ id: 'build', kind: 'build', status: 'in_progress' }),
88
+ step({ id: 'publish', kind: 'publish', status: 'pending' })
89
+ ]);
90
+ expect(steps[1].blockedBy).toEqual<PublishStepKind[]>(['build']);
91
+ expect(steps[1].status).toBe('pending');
92
+ });
93
+
94
+ test('does not overwrite a step that already failed', () => {
95
+ const steps = applyBlockedByEligibility([
96
+ step({ id: 'build', kind: 'build', status: 'failed' }),
97
+ step({ id: 'publish', kind: 'publish', status: 'failed' })
98
+ ]);
99
+ expect(steps[1].blockedBy).toBeUndefined();
100
+ expect(steps[1].status).toBe('failed');
101
+ });
102
+
103
+ test('holds an in_progress publish as pending until earlier policy gates clear', () => {
104
+ const steps = applyBlockedByEligibility([
105
+ step({
106
+ id: 'policy_gates:before_deploy',
107
+ kind: 'policy_gates',
108
+ status: 'in_progress',
109
+ displayName: 'Policy Agents'
110
+ }),
111
+ step({
112
+ id: 'publish',
113
+ kind: 'publish',
114
+ status: 'in_progress',
115
+ displayName: 'Publishing…'
116
+ })
117
+ ]);
118
+ expect(steps[1].status).toBe('pending');
119
+ expect(steps[1].blockedBy).toEqual<PublishStepKind[]>(['policy_gates']);
120
+ expect(steps[1].displayName).toBe('Publish');
121
+ });
122
+
123
+ test('clears blockedBy when all earlier steps are clear', () => {
124
+ const steps = applyBlockedByEligibility([
125
+ step({ id: 'build', kind: 'build', status: 'passed' }),
126
+ step({ id: 'publish', kind: 'publish', status: 'pending', blockedBy: ['build'] })
127
+ ]);
128
+ expect(steps[1].blockedBy).toBeUndefined();
129
+ });
130
+
131
+ test('does not block policy gates on an unfinished build (they run concurrently)', () => {
132
+ const steps = applyBlockedByEligibility([
133
+ step({ id: 'build', kind: 'build', status: 'in_progress' }),
134
+ step({
135
+ id: 'policy_gates:before_deploy',
136
+ kind: 'policy_gates',
137
+ status: 'in_progress',
138
+ displayName: 'Policy Agents'
139
+ })
140
+ ]);
141
+ expect(steps[1].blockedBy).toBeUndefined();
142
+ expect(steps[1].status).toBe('in_progress');
143
+ expect(steps[1].displayName).toBe('Policy Agents');
144
+ });
145
+
146
+ test('keeps a pending policy gate idle rather than waiting on the build', () => {
147
+ const steps = applyBlockedByEligibility([
148
+ step({ id: 'build', kind: 'build', status: 'in_progress' }),
149
+ step({ id: 'policy_gates:before_deploy', kind: 'policy_gates', status: 'pending' })
150
+ ]);
151
+ expect(steps[1].blockedBy).toBeUndefined();
152
+ expect(steps[1].status).toBe('pending');
153
+ });
154
+
155
+ test('still blocks publish on both the build and policy gates', () => {
156
+ const steps = applyBlockedByEligibility([
157
+ step({ id: 'build', kind: 'build', status: 'in_progress' }),
158
+ step({ id: 'policy_gates:before_deploy', kind: 'policy_gates', status: 'in_progress' }),
159
+ step({ id: 'publish', kind: 'publish', status: 'pending' })
160
+ ]);
161
+ expect(steps[2].blockedBy).toEqual<PublishStepKind[]>(['build', 'policy_gates']);
162
+ expect(steps[2].status).toBe('pending');
163
+ });
164
+
165
+ test('lists each unclear kind at most once in blockedBy', () => {
166
+ // Two earlier steps can share a kind (e.g. multiple database_* rows). The
167
+ // rail waits on kinds, not step ids, so blockedBy must not repeat a kind.
168
+ const steps = applyBlockedByEligibility([
169
+ step({
170
+ id: 'database_provisioning:a',
171
+ kind: 'database_provisioning',
172
+ status: 'in_progress'
173
+ }),
174
+ step({
175
+ id: 'database_provisioning:b',
176
+ kind: 'database_provisioning',
177
+ status: 'pending'
178
+ }),
179
+ step({ id: 'publish', kind: 'publish', status: 'pending' })
180
+ ]);
181
+ expect(steps[2].blockedBy).toEqual<PublishStepKind[]>(['database_provisioning']);
182
+ });
183
+ });
184
+
185
+ describe('buildPublishPlan', () => {
186
+ test('orders, applies eligibility, and rolls up', () => {
187
+ const plan = buildPublishPlan(TARGET, [
188
+ step({ id: 'publish', kind: 'publish', status: 'pending' }),
189
+ step({ id: 'build', kind: 'build', status: 'in_progress' })
190
+ ]);
191
+ expect(plan.target).toEqual(TARGET);
192
+ expect(plan.steps.map((s) => s.id)).toEqual(['build', 'publish']);
193
+ expect(plan.steps[1].blockedBy).toEqual(['build']);
194
+ expect(plan.status).toBe('in_progress');
195
+ });
196
+
197
+ test('an empty plan is passed', () => {
198
+ expect(buildPublishPlan(TARGET, []).status).toBe('passed');
199
+ });
200
+ });
@@ -0,0 +1,114 @@
1
+ import type { PublishPlan, PublishPlanTarget, PublishStep, PublishStepKind, PublishStepStatus } from './index.js';
2
+ import { PUBLISH_STEP_SPINE } from './index.js';
3
+
4
+ /** Worst-first. When summarizing many steps into one status, first match wins. */
5
+ const STATUS_PRECEDENCE: PublishStepStatus[] = [
6
+ 'failed',
7
+ 'blocked',
8
+ 'action_required',
9
+ 'stale',
10
+ 'in_progress',
11
+ 'pending',
12
+ 'advisory',
13
+ 'passed',
14
+ 'skipped'
15
+ ];
16
+
17
+ const CLEAR_STATUSES = new Set<PublishStepStatus>(['passed', 'advisory', 'skipped']);
18
+
19
+ /** Collapse ordered steps into the single status shown on the plan. Empty = passed. */
20
+ export function rollUpPublishPlan(steps: PublishStep[]): PublishStepStatus {
21
+ const present = new Set(steps.map((s) => s.status));
22
+ return STATUS_PRECEDENCE.find((s) => present.has(s)) ?? 'passed';
23
+ }
24
+
25
+ /** Order steps by the fixed spine; unknown ids are appended in input order. */
26
+ export function orderPublishSteps(steps: PublishStep[]): PublishStep[] {
27
+ const byId = new Map(steps.map((s) => [s.id, s]));
28
+ const ordered: PublishStep[] = [];
29
+ for (const id of PUBLISH_STEP_SPINE) {
30
+ const step = byId.get(id);
31
+ if (step) {
32
+ ordered.push(step);
33
+ byId.delete(id);
34
+ }
35
+ }
36
+ for (const step of steps) {
37
+ if (byId.has(step.id)) {
38
+ ordered.push(step);
39
+ byId.delete(step.id);
40
+ }
41
+ }
42
+ return ordered;
43
+ }
44
+
45
+ function isClear(status: PublishStepStatus): boolean {
46
+ return CLEAR_STATUSES.has(status);
47
+ }
48
+
49
+ /** Idle label when a step is held waiting on earlier work (not actively running). */
50
+ const PENDING_DISPLAY_NAMES: Record<PublishStepKind, string> = {
51
+ build: 'Build',
52
+ database_migration: 'Database migration',
53
+ database_provisioning: 'Database provisioning',
54
+ policy_gates: 'Policy Agents',
55
+ publish: 'Publish'
56
+ };
57
+
58
+ /**
59
+ * Kinds that execute concurrently and therefore must not appear as each other's
60
+ * prerequisite. The SABS build and the policy agents both key off the commit and
61
+ * run in parallel — policy agents analyze the committed code directly and do not
62
+ * wait for the build artifact — so neither should show "Waiting on" the other in
63
+ * the rail. They both still gate the final publish.
64
+ */
65
+ const CONCURRENT_KINDS = new Set<PublishStepKind>(['build', 'policy_gates']);
66
+
67
+ /** An earlier unclear step gates a later step unless the two run concurrently. */
68
+ function earlierKindBlocks(earlier: PublishStepKind, current: PublishStepKind): boolean {
69
+ return !(CONCURRENT_KINDS.has(earlier) && CONCURRENT_KINDS.has(current));
70
+ }
71
+
72
+ /**
73
+ * For each step that is not yet clear, set `blockedBy` to kinds of earlier
74
+ * steps that are not clear and that actually gate it (concurrent steps such as
75
+ * build and policy gates do not gate each other). Premature `in_progress` (e.g.
76
+ * deploy already QUEUED while policy gates still run) is held as `pending` so
77
+ * the rail does not show "Publishing…" ahead of unfinished prerequisites. Failed
78
+ * and other terminal statuses are left alone. Clears blockedBy when the gating
79
+ * steps clear.
80
+ */
81
+ export function applyBlockedByEligibility(steps: PublishStep[]): PublishStep[] {
82
+ // Kind set (not a list): multiple earlier steps can share a kind, but the
83
+ // rail waits on kinds — blockedBy must not repeat the same kind.
84
+ const unclearKinds = new Set<PublishStepKind>();
85
+ return steps.map((step) => {
86
+ const blockers = [...unclearKinds].filter((kind) => earlierKindBlocks(kind, step.kind));
87
+ if (blockers.length > 0 && (step.status === 'pending' || step.status === 'in_progress')) {
88
+ const next: PublishStep = {
89
+ ...step,
90
+ status: 'pending',
91
+ blockedBy: blockers,
92
+ displayName: PENDING_DISPLAY_NAMES[step.kind]
93
+ };
94
+ unclearKinds.add(step.kind);
95
+ return next;
96
+ }
97
+ if (step.status === 'pending') {
98
+ const rest = { ...step };
99
+ delete rest.blockedBy;
100
+ unclearKinds.add(step.kind);
101
+ return rest;
102
+ }
103
+ if (!isClear(step.status)) {
104
+ unclearKinds.add(step.kind);
105
+ }
106
+ return step;
107
+ });
108
+ }
109
+
110
+ /** Order, apply eligibility, roll up. */
111
+ export function buildPublishPlan(target: PublishPlanTarget, steps: PublishStep[]): PublishPlan {
112
+ const ordered = applyBlockedByEligibility(orderPublishSteps(steps));
113
+ return { status: rollUpPublishPlan(ordered), steps: ordered, target };
114
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, expect, test } from 'vitest';
2
+
3
+ import { PUBLISH_STEP_SPINE, PublishStepId } from './index.js';
4
+
5
+ describe('PublishStepId', () => {
6
+ test('exposes the stable wire id for every publish step', () => {
7
+ expect(PublishStepId).toEqual({
8
+ build: 'build',
9
+ databaseMigration: 'database_migration',
10
+ databaseProvisioning: 'database_provisioning',
11
+ policyGatesBeforeBuild: 'policy_gates:before_build',
12
+ policyGatesBeforeDeploy: 'policy_gates:before_deploy',
13
+ publish: 'publish'
14
+ });
15
+ });
16
+
17
+ test('the spine is made up only of known step ids, in fixed order', () => {
18
+ expect(PUBLISH_STEP_SPINE).toEqual([
19
+ PublishStepId.policyGatesBeforeBuild,
20
+ PublishStepId.build,
21
+ PublishStepId.policyGatesBeforeDeploy,
22
+ PublishStepId.databaseProvisioning,
23
+ PublishStepId.databaseMigration,
24
+ PublishStepId.publish
25
+ ]);
26
+ });
27
+
28
+ test('every known step id appears in the spine exactly once', () => {
29
+ const ids = Object.values(PublishStepId);
30
+ for (const id of ids) {
31
+ expect(PUBLISH_STEP_SPINE.filter((spineId) => spineId === id)).toHaveLength(1);
32
+ }
33
+ expect(PUBLISH_STEP_SPINE).toHaveLength(ids.length);
34
+ });
35
+ });
@@ -152,7 +152,7 @@ export enum ResourceTypeEnum {
152
152
  // ORGANIZATION_REQUESTS = 'org.requests',
153
153
  ORGANIZATION_USERS = 'org.users',
154
154
  ORG_KNOWLEDGE = 'org_knowledge',
155
- POLICY_AGENTS = 'policy_agents',
155
+ POLICIES = 'policies',
156
156
  PROFILES = 'profiles',
157
157
  REPOSITORIES = 'repos',
158
158
  ROLES = 'roles',
@@ -225,7 +225,7 @@ export const ActionTypeByResourceType = {
225
225
  PROFILES: {
226
226
  MANAGE: ActionTypeEnum.MANAGE
227
227
  },
228
- POLICY_AGENTS: {
228
+ POLICIES: {
229
229
  MANAGE: ActionTypeEnum.MANAGE,
230
230
  VIEW: ActionTypeEnum.VIEW
231
231
  },