@bongos/core 1.24.14 → 1.24.15
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/.bongos-core.json +296 -136
- package/.claude/skills/blocker-review/SKILL.md +2 -0
- package/.claude/skills/session-board/SKILL.md +29 -0
- package/.claude/skills/strand-fix/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +23 -2
- package/clients/bongos-client/index.mjs +14 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
- package/docs/api/openapi.json +369 -6
- package/docs/api-reference.md +19 -4
- package/docs/architecture.md +6 -0
- package/docs/copy-inventory.md +138 -123
- package/docs/copy-registry.json +296 -171
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +2 -2
- package/docs/page-inventory.json +6 -1
- package/docs/page-readings.json +1040 -1021
- package/migrations/core_289_task_short_name.sql +34 -0
- package/modules/ai-studio/db.js +10 -0
- package/modules/ai-studio/gemini.js +73 -5
- package/modules/ai-studio/openrouter.js +17 -2
- package/modules/ai-studio/routes/ai-studio.js +7 -1
- package/modules/ai-studio/studio.js +8 -0
- package/modules/builder-settings/checkout-freshness.js +71 -0
- package/modules/builder-settings/routes/settings.js +26 -0
- package/modules/builder-settings/settings.js +2 -0
- package/modules/economy/migrations/economy_002_project_account.sql +83 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/project-account.js +166 -0
- package/modules/economy/routes/project-account.js +40 -0
- package/modules/hall-ui/public/ai-studio.css +3 -0
- package/modules/hall-ui/public/ai-studio.html +2 -1
- package/modules/hall-ui/public/ai-studio.js +72 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/github-chip.js +85 -0
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +2 -2
- package/modules/hall-ui/public/market-watch-item.js +10 -3
- package/modules/hall-ui/public/market-watch-item.states.json +41 -0
- package/modules/hall-ui/public/market-watch-lib.js +36 -5
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +5 -0
- package/modules/hall-ui/public/market-watch.html +2 -2
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/my-agents.js +3 -0
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/shell.js +22 -22
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +28 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/blockers.js +13 -7
- package/modules/lifecycle/db-claim-reads.js +1 -1
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/db-tasks.js +4 -4
- package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/pay-type-gates.js +180 -0
- package/modules/lifecycle/publish-reconciler.js +31 -1
- package/modules/lifecycle/rebase-gate.js +3 -2
- package/modules/lifecycle/routes/claims.js +3 -2
- package/modules/lifecycle/routes/pay-type-gates.js +59 -0
- package/modules/lifecycle/routes/project-settings.js +4 -0
- package/modules/lifecycle/routes/task-write-routes.js +43 -0
- package/modules/lifecycle/ship-card.js +4 -1
- package/modules/lifecycle/task-name-db.js +55 -0
- package/modules/lifecycle/task-name.js +140 -0
- package/modules/market-watch/db.js +10 -1
- package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
- package/modules/market-watch/pages.js +20 -1
- package/modules/market-watch/proposals.js +57 -7
- package/modules/market-watch/routes/pages.js +2 -0
- package/modules/market-watch/routes/rules.js +4 -0
- package/modules/platform-identity/terms-acceptance.js +9 -9
- package/modules/public-landing/public/account-google.states.json +14 -0
- package/modules/public-landing/public/assets/cosmos.css +12 -0
- package/modules/public-landing/public/assets/hub-bar.js +44 -1
- package/modules/public-landing/public/privacy.html +5 -1
- package/modules/public-landing/public/projects-google.states.json +14 -0
- package/modules/public-landing/public/projects.html +39 -0
- package/modules/public-landing/public/terms.html +12 -2
- package/modules/ui-design/kit/fixtures/me-google.json +10 -0
- package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
- package/modules/ui-design/kit/serve.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +49 -1
- package/scripts/bongos/claim.js +4 -2
- package/scripts/bongos/land-watch.js +1 -0
- package/scripts/bongos/report-checkout.js +30 -0
- package/scripts/bongos/run-unit-tests.js +11 -0
- package/scripts/bongos/sessions-board.js +264 -0
- package/scripts/bongos/ship-finish.js +1 -0
- package/scripts/bongos/ship-flow.js +5 -2
- package/scripts/bongos/strand-lease.js +5 -3
- package/scripts/bongos/strand-watch.js +38 -7
- package/scripts/gds/report-checkout.js +7 -0
- package/scripts/gds/sessions-board.js +7 -0
- package/src/bongos/agent-standing-rules.js +13 -3
- package/src/bongos/routes/me.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/agent_standing_rules.mjs +18 -1
- package/tests/ai_studio_billing.mjs +13 -0
- package/tests/ai_studio_gemini_models.mjs +81 -0
- package/tests/ai_studio_requests.mjs +24 -0
- package/tests/blocker_detail_hydration.mjs +5 -3
- package/tests/blocker_tasks_named_db.mjs +74 -0
- package/tests/claim_gate_ci_unblock.mjs +1 -1
- package/tests/github_chip.mjs +217 -0
- package/tests/hall_ai_studio.mjs +129 -5
- package/tests/host_topology_skips.mjs +1 -0
- package/tests/hub_account_page.mjs +3 -2
- package/tests/landing_legal_pages.mjs +3 -2
- package/tests/market_watch_data_db.mjs +6 -0
- package/tests/market_watch_pages.mjs +87 -2
- package/tests/market_watch_rules.mjs +101 -5
- package/tests/market_watch_rules_db.mjs +42 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/pay_terms.mjs +8 -0
- package/tests/pay_type_gates.mjs +234 -0
- package/tests/pay_type_gates_db.mjs +84 -0
- package/tests/project_account.mjs +125 -0
- package/tests/project_account_db.mjs +97 -0
- package/tests/self_healing_start.mjs +162 -0
- package/tests/sessions_board.mjs +153 -0
- package/tests/stop_refcheck.mjs +40 -0
- package/tests/task_name.mjs +237 -0
- package/tests/task_name_db.mjs +79 -0
- package/tests/terms_acceptance.mjs +25 -3
- package/tests/terms_grandfather_db.mjs +11 -4
|
@@ -21,7 +21,7 @@ async function getActiveClaimsForBuilder(builderId) {
|
|
|
21
21
|
`SELECT c.id AS claim_id, c.task_id, c.worktree_name, c.claimed_at,
|
|
22
22
|
c.head_sha, c.reserved_migration_number,
|
|
23
23
|
c.release_requested_by, c.release_requested_at, c.release_requested_note,
|
|
24
|
-
t.title, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key,
|
|
24
|
+
t.title, t.short_name, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key,
|
|
25
25
|
-- task 1003855: the craft of the claimed work. A speciality contract is
|
|
26
26
|
-- craft-scoped, so the Conductor needs to know which craft is in play
|
|
27
27
|
-- before it can pick the right one to inject.
|
|
@@ -485,7 +485,7 @@ const UNPROVABLE_STRAND_REASON = 'strand:no_branch_no_tip';
|
|
|
485
485
|
// release it.
|
|
486
486
|
async function gatingRebaseTasksForBuilder(builderId, client = pool, graceMs = UNPROVABLE_GATE_GRACE_MS) {
|
|
487
487
|
const { rows } = await client.query(
|
|
488
|
-
`SELECT id, title, needs_rebase_reason, needs_rebase_at
|
|
488
|
+
`SELECT id, title, short_name, needs_rebase_reason, needs_rebase_at
|
|
489
489
|
FROM tasks
|
|
490
490
|
WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = $1
|
|
491
491
|
AND NOT (
|
|
@@ -501,7 +501,7 @@ async function gatingRebaseTasksForBuilder(builderId, client = pool, graceMs = U
|
|
|
501
501
|
|
|
502
502
|
async function needsRebaseTasksForBuilder(builderId, client = pool) {
|
|
503
503
|
const { rows } = await client.query(
|
|
504
|
-
`SELECT id, title, needs_rebase_reason, needs_rebase_at
|
|
504
|
+
`SELECT id, title, short_name, needs_rebase_reason, needs_rebase_at
|
|
505
505
|
FROM tasks
|
|
506
506
|
WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = $1
|
|
507
507
|
ORDER BY needs_rebase_at ASC NULLS LAST, id ASC`,
|
|
@@ -24,6 +24,7 @@ const { gateOnSpecTask } = require('./goal-spec-task.js');
|
|
|
24
24
|
const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
|
|
25
25
|
const { notPageMarkerSql } = require('./page-tweak-artist.js');
|
|
26
26
|
const { needsFilterWhere, capabilityFeedWhere } = require('./task-needs-gate.js');
|
|
27
|
+
const { deriveTaskName } = require('./task-name.js');
|
|
27
28
|
|
|
28
29
|
// -------------------------------------------------------------------------
|
|
29
30
|
// Tasks
|
|
@@ -270,13 +271,13 @@ async function createTask({
|
|
|
270
271
|
est_minutes, est_cost_usd, manual_degree, priority,
|
|
271
272
|
automation_tag, credits_reward, source, source_ref, value_summary,
|
|
272
273
|
kind, discipline, parallel_safe, newcomer_friendly, needs_migration,
|
|
273
|
-
security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at, needs)
|
|
274
|
+
security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at, needs, short_name)
|
|
274
275
|
VALUES ($1, $2, $3, $4, $5,
|
|
275
276
|
$6, $7, $8, $9,
|
|
276
277
|
$10, $11, $12, $13, $14,
|
|
277
278
|
$15, $16, $17, $18, $19,
|
|
278
279
|
$20, $21, $22, $23, $24,
|
|
279
|
-
CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END, $25)
|
|
280
|
+
CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END, $25, $26)
|
|
280
281
|
RETURNING *`,
|
|
281
282
|
[
|
|
282
283
|
versionId,
|
|
@@ -303,7 +304,7 @@ async function createTask({
|
|
|
303
304
|
moduleKey ?? null,
|
|
304
305
|
goalIdEffective ?? null,
|
|
305
306
|
createdBy ?? null,
|
|
306
|
-
Array.isArray(needs) ? needs : [],
|
|
307
|
+
Array.isArray(needs) ? needs : [], deriveTaskName(title, description), // $26: the short name (task 1004958)
|
|
307
308
|
]
|
|
308
309
|
);
|
|
309
310
|
// task 1677 / ADR 0096: audit each creation-time auto-assignment so the trail is
|
|
@@ -486,7 +487,6 @@ async function updateTaskPriority(id, priority) { return updateTaskColumn(id, 'p
|
|
|
486
487
|
// keeps on every other value_summary write.
|
|
487
488
|
async function updateTaskValueSummary(id, valueSummary) { return updateTaskColumn(id, 'value_summary', valueSummary); }
|
|
488
489
|
|
|
489
|
-
|
|
490
490
|
async function updateTaskDiscipline(id, discipline) { return updateTaskColumn(id, 'discipline', discipline); }
|
|
491
491
|
|
|
492
492
|
// #360 / ADR 0018 + V3.R15 #239: open (or close) a task to Xenos claims by
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- lifecycle_019_pay_type_gates.sql — task 1004991 (goal 1000092; ADR 0385, "Pay-type
|
|
2
|
+
-- gates").
|
|
3
|
+
--
|
|
4
|
+
-- A pay type becomes switchable only after its gate is cleared and recorded. A
|
|
5
|
+
-- gate is a short list of requirements per pay type (the list lives in
|
|
6
|
+
-- modules/lifecycle/pay-type-gates.js, taken from the ADR's table); this table
|
|
7
|
+
-- records, for each requirement, who said it is met, when, and the evidence they
|
|
8
|
+
-- point at. The gate is cleared when every requirement's NEWEST row says 'cleared'.
|
|
9
|
+
-- The instance is the project, so there is no project column: one database, one
|
|
10
|
+
-- project.
|
|
11
|
+
--
|
|
12
|
+
-- A LIFECYCLE MIGRATION, NOT A CORE ONE: a brand-new table the lifecycle module
|
|
13
|
+
-- owns (pay-type-gates.js is its only reader and writer), the lifecycle_018_pay_terms
|
|
14
|
+
-- precedent.
|
|
15
|
+
--
|
|
16
|
+
-- RECORDS ONLY. Nothing here moves money. Clearing a requirement is a person's act
|
|
17
|
+
-- (often with counsel) and the evidence column holds only a REFERENCE to it (a
|
|
18
|
+
-- document title, a ticket, a processor verdict id) — never identity, tax or KYC
|
|
19
|
+
-- data, which Bongos does not store.
|
|
20
|
+
--
|
|
21
|
+
-- APPEND-ONLY. Every clearing or revoking is a new row; the newest row per
|
|
22
|
+
-- (pay_type, requirement) is current, and a row trigger refuses UPDATE and DELETE,
|
|
23
|
+
-- so the table is its own audit trail. The database also refuses outright any row for
|
|
24
|
+
-- eu_labour: until a later ADR enables it, no code path, however forged, can clear it.
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS lifecycle_pay_type_gates (
|
|
27
|
+
id bigserial PRIMARY KEY,
|
|
28
|
+
pay_type text NOT NULL CHECK (pay_type IN
|
|
29
|
+
('cash', 'equity', 'module_share', 'credit_pool', 'non_monetary', 'self_managed')),
|
|
30
|
+
requirement text NOT NULL CHECK (char_length(requirement) BETWEEN 1 AND 64),
|
|
31
|
+
state text NOT NULL CHECK (state IN ('cleared', 'revoked')),
|
|
32
|
+
evidence_ref text NOT NULL CHECK (char_length(evidence_ref) BETWEEN 1 AND 500),
|
|
33
|
+
decided_by bigint NOT NULL REFERENCES builders(id),
|
|
34
|
+
decided_at timestamptz NOT NULL DEFAULT now()
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
-- The current state of a requirement: newest first.
|
|
38
|
+
CREATE INDEX IF NOT EXISTS lifecycle_pay_type_gates_current
|
|
39
|
+
ON lifecycle_pay_type_gates (pay_type, requirement, decided_at DESC, id DESC);
|
|
40
|
+
|
|
41
|
+
CREATE OR REPLACE FUNCTION lifecycle_pay_type_gates_refuse_change() RETURNS trigger AS $$
|
|
42
|
+
BEGIN
|
|
43
|
+
RAISE EXCEPTION 'pay-type gates are a history: % on % is refused; record a new row instead', TG_OP, TG_TABLE_NAME
|
|
44
|
+
USING ERRCODE = 'check_violation';
|
|
45
|
+
END;
|
|
46
|
+
$$ LANGUAGE plpgsql;
|
|
47
|
+
|
|
48
|
+
DROP TRIGGER IF EXISTS lifecycle_pay_type_gates_append_only ON lifecycle_pay_type_gates;
|
|
49
|
+
CREATE TRIGGER lifecycle_pay_type_gates_append_only BEFORE UPDATE OR DELETE ON lifecycle_pay_type_gates
|
|
50
|
+
FOR EACH ROW EXECUTE FUNCTION lifecycle_pay_type_gates_refuse_change();
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// modules/lifecycle/pay-type-gates.js — a pay type may be put on a task only after its
|
|
2
|
+
// gate is cleared for the project (task 1004991, goal 1000092; ADR 0385 "Pay-type gates").
|
|
3
|
+
//
|
|
4
|
+
// A gate is a fixed list of requirements per pay type, taken from the ADR's table and
|
|
5
|
+
// written down here as data. A person records each requirement as cleared (with a
|
|
6
|
+
// reference to the evidence) or revoked; the gate is cleared when every requirement's
|
|
7
|
+
// newest record says cleared. The pay-terms write paths ask `refuseUnclearedGates`
|
|
8
|
+
// before they write, so the server refuses a pay term whose type has an open gate.
|
|
9
|
+
//
|
|
10
|
+
// FOUR RULES THIS FILE HOLDS:
|
|
11
|
+
// 1. The list is data, not prose: REQUIREMENTS is the one place the ADR's table lives
|
|
12
|
+
// in code, and the table pins it against the pay-type list pay-terms.js accepts.
|
|
13
|
+
// 2. eu_labour can never be cleared. It is not in any list a write accepts and the
|
|
14
|
+
// table's CHECK refuses a row for it, so no request, however forged, reaches a
|
|
15
|
+
// cleared state (a later ADR enables it, per ADR 0385's EU labour row).
|
|
16
|
+
// 3. self_managed clears with one act, the acknowledgement that Bongos moves and
|
|
17
|
+
// guarantees nothing. Nothing else is asked of it.
|
|
18
|
+
// 4. Records only. Evidence is a REFERENCE a person typed; Bongos stores no identity,
|
|
19
|
+
// tax or KYC data here, and nothing in this file moves money.
|
|
20
|
+
//
|
|
21
|
+
// Who may record a requirement is decided by the ROUTE (the project owner, on the
|
|
22
|
+
// server — ADR 0016); who recorded it comes from the signed-in builder, never the body.
|
|
23
|
+
|
|
24
|
+
const api = require('../../src/module-api');
|
|
25
|
+
const { PAY_TYPES, SELF_MANAGED_LABEL } = require('./pay-terms');
|
|
26
|
+
|
|
27
|
+
const NEVER_CLEARABLE = ['eu_labour'];
|
|
28
|
+
const ACKNOWLEDGEMENT = 'bongos_moves_and_guarantees_nothing';
|
|
29
|
+
const EVIDENCE_MAX = 500;
|
|
30
|
+
|
|
31
|
+
// The machine-checkable list per pay type (ADR 0385, "Must exist first").
|
|
32
|
+
const REQUIREMENTS = {
|
|
33
|
+
cash: [
|
|
34
|
+
['processor_onboarding', 'Processor onboarding done'],
|
|
35
|
+
['tax_details', 'Tax details collected'],
|
|
36
|
+
['age_check', 'Age check passed'],
|
|
37
|
+
['sanctions_screening', 'Sanctions screening done'],
|
|
38
|
+
['signed_rule', 'Signed pay rule on file'],
|
|
39
|
+
],
|
|
40
|
+
equity: [
|
|
41
|
+
['counsel_approved_structure', 'The project\'s own counsel-approved structure'],
|
|
42
|
+
['one_signature_per_grant', 'One signature per grant'],
|
|
43
|
+
['no_percentage_fee', 'No percentage fee taken'],
|
|
44
|
+
['wording_filter', 'Credit and valuation wording filter in force'],
|
|
45
|
+
],
|
|
46
|
+
module_share: [
|
|
47
|
+
['standard_module_licence', 'Standard module licence'],
|
|
48
|
+
['takedown_contact', 'Takedown contact named'],
|
|
49
|
+
['payee_onboarding', 'Payee onboarding done'],
|
|
50
|
+
],
|
|
51
|
+
credit_pool: [
|
|
52
|
+
['credit_wording_in_force', 'Credit wording rule (ADR 0385 decision 9) in force'],
|
|
53
|
+
['model_inputs_visible', 'Model inputs visible to the people affected'],
|
|
54
|
+
['signed_rule', 'Signed pay rule on file'],
|
|
55
|
+
],
|
|
56
|
+
non_monetary: [
|
|
57
|
+
['guardian_consent_13_15', 'Guardian consent for ages 13 to 15'],
|
|
58
|
+
['no_cash_redemption', 'No cash redemption of any credit'],
|
|
59
|
+
],
|
|
60
|
+
self_managed: [
|
|
61
|
+
[ACKNOWLEDGEMENT, 'Acknowledged: Bongos moves and guarantees nothing'],
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function requirementIds(payType) {
|
|
66
|
+
return (REQUIREMENTS[payType] || []).map(([id]) => id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// PURE. The verdict for one pay type, from its current rows ({ requirement, state,
|
|
70
|
+
// evidence_ref, decided_by, decided_at }, newest per requirement). A requirement with
|
|
71
|
+
// no row, or whose newest row is revoked, is missing. Rows for requirements not in the
|
|
72
|
+
// list are ignored, so a stray row can never clear a gate.
|
|
73
|
+
function gateVerdict(payType, currentRows) {
|
|
74
|
+
if (NEVER_CLEARABLE.includes(payType)) {
|
|
75
|
+
return { pay_type: payType, cleared: false, never: true, requirements: [], missing: [] };
|
|
76
|
+
}
|
|
77
|
+
const byReq = new Map((currentRows || []).filter((r) => r.pay_type == null || r.pay_type === payType).map((r) => [r.requirement, r]));
|
|
78
|
+
const requirements = (REQUIREMENTS[payType] || []).map(([id, label]) => {
|
|
79
|
+
const r = byReq.get(id);
|
|
80
|
+
const met = !!r && r.state === 'cleared';
|
|
81
|
+
return {
|
|
82
|
+
requirement: id,
|
|
83
|
+
label,
|
|
84
|
+
met,
|
|
85
|
+
evidence_ref: r ? r.evidence_ref : null,
|
|
86
|
+
cleared_by: met ? r.decided_by : null,
|
|
87
|
+
cleared_at: met ? r.decided_at : null,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
const missing = requirements.filter((x) => !x.met).map((x) => x.requirement);
|
|
91
|
+
return { pay_type: payType, cleared: requirements.length > 0 && missing.length === 0, never: false, requirements, missing };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The newest row per (pay_type, requirement) for these pay types, one read.
|
|
95
|
+
async function readCurrentRows(payTypes, { db } = {}) {
|
|
96
|
+
const exec = db || api.pool;
|
|
97
|
+
const { rows } = await exec.query(
|
|
98
|
+
`SELECT DISTINCT ON (pay_type, requirement) pay_type, requirement, state, evidence_ref, decided_by, decided_at
|
|
99
|
+
FROM lifecycle_pay_type_gates
|
|
100
|
+
WHERE pay_type = ANY($1::text[])
|
|
101
|
+
ORDER BY pay_type, requirement, decided_at DESC, id DESC`, [payTypes]);
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Every pay type's verdict, for the owner's view (and the settings page).
|
|
106
|
+
async function readGates({ db } = {}) {
|
|
107
|
+
const rows = await readCurrentRows(PAY_TYPES, { db });
|
|
108
|
+
const gates = PAY_TYPES.map((p) => gateVerdict(p, rows.filter((r) => r.pay_type === p)));
|
|
109
|
+
for (const p of NEVER_CLEARABLE) gates.push(gateVerdict(p, []));
|
|
110
|
+
return gates;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// PURE. A verdict as a non-owner may see it: met or missing per requirement, without the
|
|
114
|
+
// evidence reference a person typed or who cleared it (those are the owner's own records).
|
|
115
|
+
function withoutEvidence(gate) {
|
|
116
|
+
return { ...gate, requirements: gate.requirements.map(({ evidence_ref, cleared_by, cleared_at, ...rest }) => rest) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Checks a list of normalized terms against the gates. Returns null when every type
|
|
120
|
+
// in it is cleared, else { code, message, pay_type, missing } for the first that is
|
|
121
|
+
// not. One read however many terms there are, and none for an empty list.
|
|
122
|
+
async function refuseUnclearedGates(terms, { db } = {}) {
|
|
123
|
+
const types = [...new Set((terms || []).map((t) => t.pay_type))];
|
|
124
|
+
if (!types.length) return null;
|
|
125
|
+
const rows = await readCurrentRows(types, { db });
|
|
126
|
+
for (const payType of types) {
|
|
127
|
+
const v = gateVerdict(payType, rows.filter((r) => r.pay_type === payType));
|
|
128
|
+
if (!v.cleared) {
|
|
129
|
+
return {
|
|
130
|
+
code: 'pay_type_gate_not_cleared',
|
|
131
|
+
pay_type: payType,
|
|
132
|
+
missing: v.missing,
|
|
133
|
+
message: `The ${payType} pay type is not switched on for this project: its gate is not cleared (still needed: ${v.missing.join(', ')}).`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// PURE. Checks a request body to record one requirement. Returns { state, evidenceRef }
|
|
141
|
+
// or { status, code, error }. The recorder is never read from the body.
|
|
142
|
+
function normalizeGateWrite(payType, requirement, body) {
|
|
143
|
+
if (NEVER_CLEARABLE.includes(payType)) {
|
|
144
|
+
return { status: 409, code: 'pay_type_never_clearable', error: `${payType} cannot be cleared; it stays off until a later decision enables it.` };
|
|
145
|
+
}
|
|
146
|
+
if (!PAY_TYPES.includes(payType)) return { status: 404, code: 'unknown_pay_type', error: `pay type must be one of ${PAY_TYPES.join(', ')}.` };
|
|
147
|
+
if (!requirementIds(payType).includes(requirement)) {
|
|
148
|
+
return { status: 404, code: 'unknown_requirement', error: `${payType} has no such requirement; it has ${requirementIds(payType).join(', ')}.` };
|
|
149
|
+
}
|
|
150
|
+
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
151
|
+
const extra = Object.keys(b).filter((k) => !['state', 'evidence_ref', 'acknowledged'].includes(k));
|
|
152
|
+
if (extra.length) return { status: 400, code: 'unknown_field', error: `unknown field: ${extra.join(', ')}.` };
|
|
153
|
+
if (b.state !== 'cleared' && b.state !== 'revoked') return { status: 400, code: 'bad_state', error: 'state is "cleared" or "revoked".' };
|
|
154
|
+
if (requirement === ACKNOWLEDGEMENT && b.state === 'cleared') {
|
|
155
|
+
if (b.acknowledged !== true) {
|
|
156
|
+
return { status: 400, code: 'acknowledgement_required', error: `Send acknowledged: true. It records: ${SELF_MANAGED_LABEL}` };
|
|
157
|
+
}
|
|
158
|
+
return { state: 'cleared', evidenceRef: SELF_MANAGED_LABEL };
|
|
159
|
+
}
|
|
160
|
+
const ev = typeof b.evidence_ref === 'string' ? b.evidence_ref.trim() : '';
|
|
161
|
+
if (!ev || ev.length > EVIDENCE_MAX) {
|
|
162
|
+
return { status: 400, code: 'bad_evidence_ref', error: `evidence_ref is a reference to the evidence (or the reason, for a revoke), 1 to ${EVIDENCE_MAX} characters.` };
|
|
163
|
+
}
|
|
164
|
+
return { state: b.state, evidenceRef: ev };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Appends one record. Returns the gate's new verdict for that pay type.
|
|
168
|
+
async function recordRequirement({ payType, requirement, state, evidenceRef, builderId }, { db } = {}) {
|
|
169
|
+
const exec = db || api.pool;
|
|
170
|
+
await exec.query(
|
|
171
|
+
`INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by)
|
|
172
|
+
VALUES ($1, $2, $3, $4, $5)`, [payType, requirement, state, evidenceRef, builderId]);
|
|
173
|
+
const rows = await readCurrentRows([payType], { db: exec });
|
|
174
|
+
return gateVerdict(payType, rows);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
REQUIREMENTS, ACKNOWLEDGEMENT,
|
|
179
|
+
requirementIds, gateVerdict, readGates, refuseUnclearedGates, normalizeGateWrite, recordRequirement, withoutEvidence,
|
|
180
|
+
};
|
|
@@ -981,6 +981,34 @@ async function runCraftAchievementBackfill(deps = {}) {
|
|
|
981
981
|
}
|
|
982
982
|
}
|
|
983
983
|
|
|
984
|
+
// task 1004958: name the tasks that have no short name yet — every task created
|
|
985
|
+
// before names existed, and any row a path outside createTask inserted. Pure DB,
|
|
986
|
+
// idempotent (it writes only `short_name IS NULL` rows), and bounded: at most
|
|
987
|
+
// TASK_NAME_BATCHES batches a run, so the first run on a big board drains it in
|
|
988
|
+
// one go and every later run is one indexed read that finds next to nothing.
|
|
989
|
+
const TASK_NAME_BATCHES = 20;
|
|
990
|
+
async function runTaskNameBackfill(deps = {}) {
|
|
991
|
+
const { logFn, errFn } = loggers(deps);
|
|
992
|
+
const tasksDb = (deps.tasksDb && typeof deps.tasksDb.backfillTaskNames === 'function')
|
|
993
|
+
? deps.tasksDb
|
|
994
|
+
: require('./task-name-db.js');
|
|
995
|
+
const exec = deps.pool || seams.pool;
|
|
996
|
+
let named = 0;
|
|
997
|
+
try {
|
|
998
|
+
for (let i = 0, after = 0; i < TASK_NAME_BATCHES; i++) {
|
|
999
|
+
const out = await tasksDb.backfillTaskNames({ exec, after });
|
|
1000
|
+
named += out.named;
|
|
1001
|
+
after = out.next;
|
|
1002
|
+
if (!out.scanned) break;
|
|
1003
|
+
}
|
|
1004
|
+
if (named) logFn(`[publish-reconciler] task names: ${named} task(s) named`);
|
|
1005
|
+
return { named };
|
|
1006
|
+
} catch (e) {
|
|
1007
|
+
errFn(`[publish-reconciler] task name backfill (non-blocking): ${e && e.message}`);
|
|
1008
|
+
return { skipped: 'error', error: e && e.message, named };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
984
1012
|
// The sweeps one tick fires. Each is INDEPENDENT on purpose: the cost (task 1366)
|
|
985
1013
|
// and criterion (task 1002069) sweeps are pure DB — no push credential — and a
|
|
986
1014
|
// failure in either must never touch the confirmed→shipped work.
|
|
@@ -989,7 +1017,7 @@ async function runCraftAchievementBackfill(deps = {}) {
|
|
|
989
1017
|
// between runs; none means every tick (INTERVAL_MS). The publish, cost and
|
|
990
1018
|
// criterion sweeps keep the tick — a strand, an unpriced session and a newly
|
|
991
1019
|
// satisfiable criterion are all things someone is waiting on. The promotion sweep
|
|
992
|
-
// and the craft
|
|
1020
|
+
// and the craft and task-name backfills run HOURLY: the ship-time triggers already promote and
|
|
993
1021
|
// unlock on the event, so these are backlog drains, and a drain an hour late
|
|
994
1022
|
// costs nobody anything while running it every 5 minutes cost every instance
|
|
995
1023
|
// ~288 scans a day.
|
|
@@ -1000,6 +1028,7 @@ const SWEEPS = [
|
|
|
1000
1028
|
['criterion sweep', runCriterionSweep],
|
|
1001
1029
|
['promotion sweep', runPromotionSweep, HOURLY_MS],
|
|
1002
1030
|
['craft achievement backfill', runCraftAchievementBackfill, HOURLY_MS],
|
|
1031
|
+
['task name backfill', runTaskNameBackfill, HOURLY_MS],
|
|
1003
1032
|
];
|
|
1004
1033
|
|
|
1005
1034
|
// buildTick — one interval round, with a per-sweep overlap guard (task 1004924).
|
|
@@ -1083,6 +1112,7 @@ module.exports = {
|
|
|
1083
1112
|
runPromotionSweep,
|
|
1084
1113
|
runCriterionSweep,
|
|
1085
1114
|
runCraftAchievementBackfill,
|
|
1115
|
+
runTaskNameBackfill,
|
|
1086
1116
|
buildTick,
|
|
1087
1117
|
couldBeStaleGenerated,
|
|
1088
1118
|
describeTarget,
|
|
@@ -70,8 +70,9 @@ function splitRebaseGate(rows, { limit = gateLimit() } = {}) {
|
|
|
70
70
|
// the hint and the CLI can say "waiting, not on you".
|
|
71
71
|
function rebaseRefusalTasks(split) {
|
|
72
72
|
return [
|
|
73
|
-
|
|
74
|
-
...split.
|
|
73
|
+
// task 1004958: short_name rides along so every surface can write "task N - name".
|
|
74
|
+
...split.gating.map((t) => ({ id: t.id, short_name: t.short_name || null, title: t.title, reason: t.needs_rebase_reason })),
|
|
75
|
+
...split.waiting.map((t) => ({ id: t.id, short_name: t.short_name || null, title: t.title, reason: t.needs_rebase_reason, waiting: true, waits_on: t.waits_on })),
|
|
75
76
|
];
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -100,12 +100,13 @@ function taskNotReadyHint(taskId, currentStatus) {
|
|
|
100
100
|
// task 1004788: only the FIXABLE tasks gate (rebase-gate.js); a task set aside
|
|
101
101
|
// because it waits on something outside the builder is listed after them as
|
|
102
102
|
// "waiting, not on you" so nobody chases it.
|
|
103
|
+
const { taskRef } = require('../task-name.js'); // task 1004958
|
|
103
104
|
function rebaseRequiredHint(rebaseTasks) {
|
|
104
105
|
const all = rebaseTasks || [];
|
|
105
|
-
const ids = all.filter((t) => !t.waiting).map((t) =>
|
|
106
|
+
const ids = all.filter((t) => !t.waiting).map((t) => taskRef(t.id, t.short_name));
|
|
106
107
|
const waiting = all.filter((t) => t.waiting);
|
|
107
108
|
const aside = waiting.length
|
|
108
|
-
? ` Waiting, not on you (set aside; they land on their own when what they wait on clears): ${waiting.map((t) =>
|
|
109
|
+
? ` Waiting, not on you (set aside; they land on their own when what they wait on clears): ${waiting.map((t) => `${taskRef(t.id, t.short_name)} (${t.waits_on || 'outside you'})`).join('; ')}.`
|
|
109
110
|
: '';
|
|
110
111
|
return `Your queue is gated: ${ids.length} confirmed task(s) of yours are waiting to land and need you (${ids.join(', ') || 'unknown'}).${aside} Clear it with /strand-fix N (works on any machine), or bring the branch up to date with \`git merge origin/main\` and re-ship — MERGE, never rebase: server-mediated publish is append-only, and rewriting a published branch makes it un-pushable (task 1002788). /merge-mode only if this machine has a GitHub push credential. To give up on one instead, /builder-release it. If CI is simply backed up, waiting is correct: a task already landed clears the gate on its own. If main's CI is red for a reason outside your branch, the fix is still claimable: a P1 kind=infra task passes this gate (task 1004370).`;
|
|
111
112
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/routes/pay-type-gates.js — read, and record, the gate each pay type
|
|
4
|
+
// must clear before it can be put on a task (task 1004991, goal 1000092; ADR 0385
|
|
5
|
+
// "Pay-type gates"). The rules live in ../pay-type-gates.js; this file is the doorway.
|
|
6
|
+
//
|
|
7
|
+
// GET /pay-type-gates — every pay type's gate: each requirement, whether it is met, the
|
|
8
|
+
// evidence reference, and who cleared it and when. eu_labour is listed as never clearable.
|
|
9
|
+
// PUT /pay-type-gates/:payType/:requirement — body { state: 'cleared'|'revoked',
|
|
10
|
+
// evidence_ref } (or { state: 'cleared', acknowledged: true } for the self-managed
|
|
11
|
+
// acknowledgement). Appends one record; the recorder is the signed-in owner, never the
|
|
12
|
+
// body. Clearing a real gate is a person's act, so nothing here clears one by itself.
|
|
13
|
+
//
|
|
14
|
+
// MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
|
|
15
|
+
|
|
16
|
+
const express = require('express');
|
|
17
|
+
const api = require('../../../src/module-api');
|
|
18
|
+
const gates = require('../pay-type-gates');
|
|
19
|
+
|
|
20
|
+
const log = api.logger('lifecycle');
|
|
21
|
+
|
|
22
|
+
module.exports = function buildPayTypeGatesRouter() {
|
|
23
|
+
const router = express.Router();
|
|
24
|
+
|
|
25
|
+
// rank: any-builder for met/missing (the same pay information `GET /pay-terms` shows, no person's
|
|
26
|
+
// money); the evidence references and who cleared each requirement are the owner's records, so
|
|
27
|
+
// they come back for an archon only.
|
|
28
|
+
router.get('/pay-type-gates', api.requireBuilder, async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const all = await gates.readGates();
|
|
31
|
+
res.json({ gates: req.builder.rank === 'archon' ? all : all.map(gates.withoutEvidence) });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
log.error(`GET /pay-type-gates: ${err && err.message}`);
|
|
34
|
+
res.fail('pay_type_gates_read_failed', { status: 500, message: 'internal error' });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// rank: payout.approve (archon floor). Whether a pay type is legal for this project is the
|
|
39
|
+
// owner's call, often with counsel, and it is the same authority that approves payouts — no new
|
|
40
|
+
// atom is minted for it (ADR 0016: enforced here, on the server).
|
|
41
|
+
router.put('/pay-type-gates/:payType/:requirement', api.requireBuilder, api.requirePermission('payout.approve'), async (req, res) => {
|
|
42
|
+
const { payType, requirement } = req.params;
|
|
43
|
+
// Unknown fields (a forged cleared_by, say) are refused here; the values are checked by the lib.
|
|
44
|
+
if (api.validateOrRespond(req, res, { state: {}, evidence_ref: {}, acknowledged: {} })) return;
|
|
45
|
+
const parsed = gates.normalizeGateWrite(payType, requirement, req.body);
|
|
46
|
+
if (parsed.error) return res.fail(parsed.code, { status: parsed.status, message: parsed.error });
|
|
47
|
+
try {
|
|
48
|
+
const gate = await gates.recordRequirement({
|
|
49
|
+
payType, requirement, state: parsed.state, evidenceRef: parsed.evidenceRef, builderId: req.builder.id,
|
|
50
|
+
});
|
|
51
|
+
res.json({ ok: true, gate });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log.error(`PUT /pay-type-gates: ${err && err.message}`);
|
|
54
|
+
res.fail('pay_type_gates_write_failed', { status: 500, message: 'internal error' });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return router;
|
|
59
|
+
};
|
|
@@ -35,6 +35,7 @@ const api = require('../../../src/module-api');
|
|
|
35
35
|
const goalCategory = require('../goal-category');
|
|
36
36
|
const autopickup = require('../autopickup');
|
|
37
37
|
const payTermsLib = require('../pay-terms'); // task 1004990
|
|
38
|
+
const payTypeGates = require('../pay-type-gates'); // task 1004991
|
|
38
39
|
|
|
39
40
|
const log = api.logger('lifecycle');
|
|
40
41
|
|
|
@@ -551,6 +552,9 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
551
552
|
const parsed = payTermsLib.normalizePayTerms(req.body.terms);
|
|
552
553
|
if (parsed.error) return res.fail('bad_pay_terms', { status: 400, message: parsed.error });
|
|
553
554
|
try {
|
|
555
|
+
// task 1004991: the project's standing terms obey the same gate as a task's (ADR 0385).
|
|
556
|
+
const gateRefusal = await payTypeGates.refuseUnclearedGates(parsed.terms);
|
|
557
|
+
if (gateRefusal) return res.fail(gateRefusal.code, { status: 409, message: gateRefusal.message });
|
|
554
558
|
await payTermsLib.setPayTerms({ taskId: null, terms: parsed.terms, builderId: req.builder.id });
|
|
555
559
|
const policy = await payTermsLib.readProjectPayTerms();
|
|
556
560
|
res.json({ ok: true, row: payTermsRow(policy) });
|
|
@@ -29,6 +29,9 @@ const { validateOrRespond, LIMITS, parseId } = api;
|
|
|
29
29
|
const { badKind, badNeeds, detectsMigration } = require('./task-route-helpers.js');
|
|
30
30
|
const { describeCheckViolation } = require('../pg-constraint-fail.js');
|
|
31
31
|
const payTermsLib = require('../pay-terms.js'); // task 1004990
|
|
32
|
+
const payTypeGates = require('../pay-type-gates.js'); // task 1004991
|
|
33
|
+
const taskName = require('../task-name.js'); // task 1004958
|
|
34
|
+
const { updateTaskShortName } = require('../task-name-db.js');
|
|
32
35
|
const { goalNotOpenRefusal } = require('./goal-route-authz.js');
|
|
33
36
|
const { resolveUpkeepHome, isUpkeepFiling, UPKEEP_HINT } = require('../upkeep-home.js'); // task 1004597
|
|
34
37
|
// task 1003208: structured logging (pino via the doorway) — was console.*.
|
|
@@ -332,6 +335,37 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
332
335
|
}
|
|
333
336
|
});
|
|
334
337
|
|
|
338
|
+
// PATCH /tasks/:id/short-name { short_name } — rename a task's 2-3 word name, the
|
|
339
|
+
// one sessions print as "task N - <name>" (task 1004958). Its own route because the
|
|
340
|
+
// owner's rule is that the task's AUTHOR may change it, and PATCH /tasks/:id is gated
|
|
341
|
+
// on task.edit, which an author below Metic does not hold. The name is normalised
|
|
342
|
+
// to task-name.js's alphabet (three lowercase words at most), so what reaches the
|
|
343
|
+
// startup notes is a topic and nothing else; the table CHECK holds the same shape.
|
|
344
|
+
// rank: any-builder, narrowed in the handler — the task's author, or a Metic or Archon.
|
|
345
|
+
router.patch('/tasks/:id/short-name', auth.requireBuilder, async (req, res) => {
|
|
346
|
+
if (validateOrRespond(req, res, { short_name: { required: true, type: 'string', maxLength: 80 } })) return;
|
|
347
|
+
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
348
|
+
if (id === null) return;
|
|
349
|
+
const name = taskName.normaliseAuthoredName(req.body.short_name);
|
|
350
|
+
if (!name) {
|
|
351
|
+
return res.fail('bad_short_name', { status: 400, message: `short_name must be 1-${taskName.MAX_WORDS} plain words (letters, digits, hyphens), ${taskName.MAX_LEN} characters at most` });
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const task = await db.getTask(id);
|
|
355
|
+
if (!task) return res.fail('task_not_found', 404);
|
|
356
|
+
const isAuthor = task.created_by != null && String(task.created_by) === String(req.builder.id);
|
|
357
|
+
if (!isAuthor && !auth.rankMeetsThreshold(req.builder.rank, ['metic', 'archon'])) {
|
|
358
|
+
return res.fail('not_task_author', { status: 403, message: 'only the task\'s author (or a Metic or Archon) may rename it' });
|
|
359
|
+
}
|
|
360
|
+
const updated = await updateTaskShortName(id, name);
|
|
361
|
+
if (!updated) return res.fail('task_not_found', 404);
|
|
362
|
+
return res.json({ task: updated });
|
|
363
|
+
} catch (err) {
|
|
364
|
+
log.error('[gds] PATCH /tasks/:id/short-name', err);
|
|
365
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
|
|
335
369
|
// PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1),
|
|
336
370
|
// body.discipline (mig 014), body.parallel_safe
|
|
337
371
|
// (mig 028), and body.newcomer_friendly (#360 / ADR 0018 + V3.R15 #239 —
|
|
@@ -575,6 +609,15 @@ module.exports = function registerTaskWriteRoutes(router) {
|
|
|
575
609
|
payTerms = payTermsLib.normalizePayTerms(body.pay_terms);
|
|
576
610
|
if (payTerms.error) return res.fail('bad_pay_terms', { status: 400, message: payTerms.error });
|
|
577
611
|
if (!(await db.getTask(id))) return res.fail('task_not_found', 404);
|
|
612
|
+
// task 1004991: a pay type goes on a task only once its gate is cleared (ADR 0385).
|
|
613
|
+
let gateRefusal;
|
|
614
|
+
try {
|
|
615
|
+
gateRefusal = await payTypeGates.refuseUnclearedGates(payTerms.terms);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
log.error('[gds] PATCH /tasks/:id pay_terms gate read', err);
|
|
618
|
+
return res.fail('patch_failed', { status: 500, message: 'internal error' });
|
|
619
|
+
}
|
|
620
|
+
if (gateRefusal) return res.fail(gateRefusal.code, { status: 409, message: gateRefusal.message });
|
|
578
621
|
}
|
|
579
622
|
// ---- all gates passed: apply the writes. Order preserved from the old ----
|
|
580
623
|
// shape — requires_rank still runs AFTER security_sensitive so a
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// emitCard() is the one I/O helper the CLIs share (print monospace + write HTML +
|
|
20
20
|
// print the [otb-card-html] marker the skills pick up).
|
|
21
21
|
|
|
22
|
+
const { safeTaskName, taskRef } = require('./task-name.js'); // task 1004958
|
|
22
23
|
const fs = require('node:fs');
|
|
23
24
|
const os = require('node:os');
|
|
24
25
|
const path = require('node:path');
|
|
@@ -523,6 +524,8 @@ function buildCardModel(input = {}) {
|
|
|
523
524
|
|
|
524
525
|
return {
|
|
525
526
|
stage, cfg, brand, taskId,
|
|
527
|
+
// task 1004958: the task's 2-3 word name, so the confirm line reads "task N - name".
|
|
528
|
+
shortName: safeTaskName(input.shortName),
|
|
526
529
|
title: input.title || '',
|
|
527
530
|
value,
|
|
528
531
|
notes: input.notes || '',
|
|
@@ -751,7 +754,7 @@ function sanitizeOneLine(s) {
|
|
|
751
754
|
|
|
752
755
|
function confirmLine(model) {
|
|
753
756
|
const m = model || {};
|
|
754
|
-
const id = m.taskId != null ?
|
|
757
|
+
const id = m.taskId != null ? taskRef(m.taskId, m.shortName) : 'task';
|
|
755
758
|
const word = (m.cfg && m.cfg.statusWord) ? String(m.cfg.statusWord).toLowerCase() : String(m.stage || 'done');
|
|
756
759
|
// The stage's own rows already hold exactly the facts that stage cares about
|
|
757
760
|
// (buildCardModel picks them per stage); reuse them rather than re-deriving,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// modules/lifecycle/task-name-db.js — the two writes behind a task's short name
|
|
3
|
+
// (task 1004958): the author's rename and the backfill of unnamed rows.
|
|
4
|
+
//
|
|
5
|
+
// WHY ITS OWN FILE. createTask (db-tasks.js) writes the name on every new task;
|
|
6
|
+
// these two are the rest of the feature, and db-tasks.js sits at the 1,500-line
|
|
7
|
+
// ratchet. The name itself is derived ONLY in task-name.js, so a backfilled name
|
|
8
|
+
// and a born-with name can never disagree.
|
|
9
|
+
|
|
10
|
+
const api = require('../../src/module-api');
|
|
11
|
+
const { pool } = api;
|
|
12
|
+
const { deriveTaskName } = require('./task-name.js');
|
|
13
|
+
|
|
14
|
+
// The author's rename. The caller passes a name already through
|
|
15
|
+
// task-name.normaliseAuthoredName; the table CHECK holds the same shape.
|
|
16
|
+
async function updateTaskShortName(id, shortName, exec = pool) {
|
|
17
|
+
const { rows } = await exec.query(
|
|
18
|
+
'UPDATE tasks SET short_name = $1, updated_at = now() WHERE id = $2 RETURNING *',
|
|
19
|
+
[shortName, id]
|
|
20
|
+
);
|
|
21
|
+
return rows[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Name the tasks created before names existed (or by a path that skipped
|
|
25
|
+
// createTask). Reads a batch of unnamed rows, derives each name in JS and writes
|
|
26
|
+
// them in one statement; `short_name IS NULL` on the write too, so an author's
|
|
27
|
+
// edit landing in between is never overwritten. A title with no topic word keeps
|
|
28
|
+
// NULL and simply prints "task N". Stateless: the caller walks forward by passing
|
|
29
|
+
// back `next` as `after`, so a row that stays unnamed is passed over rather than
|
|
30
|
+
// re-read every batch, and two callers can never disturb each other's walk. The
|
|
31
|
+
// publish-reconciler walks from 0 once an hour.
|
|
32
|
+
async function backfillTaskNames({ after = 0, limit = 500, exec = pool } = {}) {
|
|
33
|
+
const { rows } = await exec.query(
|
|
34
|
+
'SELECT id, title, description FROM tasks WHERE short_name IS NULL AND id > $1 ORDER BY id LIMIT $2',
|
|
35
|
+
[after, limit]
|
|
36
|
+
);
|
|
37
|
+
if (!rows.length) return { named: 0, scanned: 0, next: after };
|
|
38
|
+
const next = Number(rows[rows.length - 1].id);
|
|
39
|
+
const ids = [];
|
|
40
|
+
const names = [];
|
|
41
|
+
for (const r of rows) {
|
|
42
|
+
const name = deriveTaskName(r.title, r.description);
|
|
43
|
+
if (name) { ids.push(r.id); names.push(name); }
|
|
44
|
+
}
|
|
45
|
+
if (!ids.length) return { named: 0, scanned: rows.length, next };
|
|
46
|
+
const res = await exec.query(
|
|
47
|
+
`UPDATE tasks t SET short_name = v.name
|
|
48
|
+
FROM unnest($1::bigint[], $2::text[]) AS v(id, name)
|
|
49
|
+
WHERE t.id = v.id AND t.short_name IS NULL`,
|
|
50
|
+
[ids, names]
|
|
51
|
+
);
|
|
52
|
+
return { named: res.rowCount || 0, scanned: rows.length, next };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { updateTaskShortName, backfillTaskNames };
|