patchwork-os 1.2.0-beta.2.canary.682 → 1.2.0-beta.2.canary.686
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/dist/butler/outcomeShadowLog.d.ts +15 -0
- package/dist/butler/outcomeShadowLog.js +38 -5
- package/dist/butler/outcomeShadowLog.js.map +1 -1
- package/dist/butler/promoteShadowOutcomes.d.ts +122 -0
- package/dist/butler/promoteShadowOutcomes.js +184 -0
- package/dist/butler/promoteShadowOutcomes.js.map +1 -0
- package/dist/connectors/todoist.d.ts +101 -14
- package/dist/connectors/todoist.js +32 -7
- package/dist/connectors/todoist.js.map +1 -1
- package/dist/index.js +31 -2
- package/dist/index.js.map +1 -1
- package/dist/recipes/cronClaim.d.ts +183 -0
- package/dist/recipes/cronClaim.js +241 -0
- package/dist/recipes/cronClaim.js.map +1 -0
- package/dist/recipes/scheduler.d.ts +63 -6
- package/dist/recipes/scheduler.js +115 -9
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/tools/todoist.d.ts +7 -1
- package/dist/recipes/tools/todoist.js +32 -19
- package/dist/recipes/tools/todoist.js.map +1 -1
- package/dist/workers/runWorkerShadow.d.ts +0 -11
- package/dist/workers/runWorkerShadow.js +26 -3
- package/dist/workers/runWorkerShadow.js.map +1 -1
- package/package.json +2 -2
|
@@ -100,3 +100,18 @@ export interface ShadowSummary {
|
|
|
100
100
|
export declare function summariseShadowLog(opts?: {
|
|
101
101
|
dir?: string;
|
|
102
102
|
}): ShadowSummary;
|
|
103
|
+
/**
|
|
104
|
+
* Parse well-formed rows out of raw ledger text.
|
|
105
|
+
*
|
|
106
|
+
* The single parse rule, shared by the summary and by promotion. Two readers of
|
|
107
|
+
* one append-only file with two copies of "what counts as a row" is how a
|
|
108
|
+
* report and the thing it reports on come to disagree — and here the report is
|
|
109
|
+
* what a person reads before deciding to promote.
|
|
110
|
+
*
|
|
111
|
+
* Malformed lines are SKIPPED and counted in nothing: a half-written row from
|
|
112
|
+
* an interrupted append is not evidence, and must not inflate a number someone
|
|
113
|
+
* is about to make a trust decision on.
|
|
114
|
+
*/
|
|
115
|
+
export declare function parseShadowRows(text: string): ShadowOutcomeRow[];
|
|
116
|
+
/** Every well-formed row in the ledger, oldest first. */
|
|
117
|
+
export declare function readShadowRows(dir?: string): ShadowOutcomeRow[];
|
|
@@ -130,6 +130,28 @@ export function summariseShadowLog(opts = {}) {
|
|
|
130
130
|
return empty;
|
|
131
131
|
}
|
|
132
132
|
const out = { ...empty };
|
|
133
|
+
for (const row of parseShadowRows(text)) {
|
|
134
|
+
out.total++;
|
|
135
|
+
out[row.disposition]++;
|
|
136
|
+
if (row.wouldCountAsEvidence)
|
|
137
|
+
out.wouldCount++;
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parse well-formed rows out of raw ledger text.
|
|
143
|
+
*
|
|
144
|
+
* The single parse rule, shared by the summary and by promotion. Two readers of
|
|
145
|
+
* one append-only file with two copies of "what counts as a row" is how a
|
|
146
|
+
* report and the thing it reports on come to disagree — and here the report is
|
|
147
|
+
* what a person reads before deciding to promote.
|
|
148
|
+
*
|
|
149
|
+
* Malformed lines are SKIPPED and counted in nothing: a half-written row from
|
|
150
|
+
* an interrupted append is not evidence, and must not inflate a number someone
|
|
151
|
+
* is about to make a trust decision on.
|
|
152
|
+
*/
|
|
153
|
+
export function parseShadowRows(text) {
|
|
154
|
+
const rows = [];
|
|
133
155
|
for (const line of text.split("\n")) {
|
|
134
156
|
if (!line.trim())
|
|
135
157
|
continue;
|
|
@@ -145,11 +167,22 @@ export function summariseShadowLog(opts = {}) {
|
|
|
145
167
|
row.disposition !== "unknown") {
|
|
146
168
|
continue;
|
|
147
169
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
170
|
+
if (typeof row.ref !== "string" || !row.ref)
|
|
171
|
+
continue;
|
|
172
|
+
rows.push(row);
|
|
173
|
+
}
|
|
174
|
+
return rows;
|
|
175
|
+
}
|
|
176
|
+
/** Every well-formed row in the ledger, oldest first. */
|
|
177
|
+
export function readShadowRows(dir) {
|
|
178
|
+
const p = shadowLogPath(dir);
|
|
179
|
+
if (!existsSync(p))
|
|
180
|
+
return [];
|
|
181
|
+
try {
|
|
182
|
+
return parseShadowRows(readFileSync(p, "utf-8"));
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
return [];
|
|
152
186
|
}
|
|
153
|
-
return out;
|
|
154
187
|
}
|
|
155
188
|
//# sourceMappingURL=outcomeShadowLog.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"outcomeShadowLog.js","sourceRoot":"","sources":["../../src/butler/outcomeShadowLog.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"outcomeShadowLog.js","sourceRoot":"","sources":["../../src/butler/outcomeShadowLog.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promote graded Butler shadow rows into the trust ledger — flag-gated, OFF.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a separate module and not a branch in the ingester
|
|
5
|
+
*
|
|
6
|
+
* `outcomeIngester.ts` and `errandOutcomeGrader.ts` each carry a test asserting
|
|
7
|
+
* they cannot reach `OutcomeStore` — not "do not call it", but *do not import
|
|
8
|
+
* anything that could*. That is what makes "shadow-only" a structural fact
|
|
9
|
+
* rather than a promise, and it is why a reviewer can read the grader without
|
|
10
|
+
* checking whether some path in it writes evidence.
|
|
11
|
+
*
|
|
12
|
+
* Adding promotion there would have meant deleting those guards. This module
|
|
13
|
+
* exists so they stay exactly as they are: the grader stays a pure function of
|
|
14
|
+
* observed state, the ingester stays unable to write trust, and the single
|
|
15
|
+
* place that can is this one, which does nothing else.
|
|
16
|
+
*
|
|
17
|
+
* ## Operator path only
|
|
18
|
+
*
|
|
19
|
+
* Not registered as a recipe tool, and a test asserts it. A recipe step runs AS
|
|
20
|
+
* the worker, so a worker able to reach this could promote its own filings —
|
|
21
|
+
* manufacturing the evidence that raises its own dial. Same reasoning as
|
|
22
|
+
* `outcomes confirm|reject`, which is also operator-only for exactly this.
|
|
23
|
+
*
|
|
24
|
+
* ## Flag-gated OFF, and the flag is about EVIDENCE, not about code
|
|
25
|
+
*
|
|
26
|
+
* Promotion is one-way in the way that matters: once a row is folded, the dial
|
|
27
|
+
* it moved cannot be un-moved by deleting the row, because trust replay has
|
|
28
|
+
* already absorbed it into a checkpoint. So the gate is not "is the code
|
|
29
|
+
* finished" — it is "is there enough evidence to start counting this channel".
|
|
30
|
+
*
|
|
31
|
+
* At the time of writing the shadow ledger holds ONE confirmed row. That is a
|
|
32
|
+
* real positive act, correctly attributed, and it is one row. The flag exists so
|
|
33
|
+
* the code can be reviewed, tested and merged now, and the trust decision made
|
|
34
|
+
* later on more of them, by a person, deliberately.
|
|
35
|
+
*
|
|
36
|
+
* ## What is promoted, and what is refused
|
|
37
|
+
*
|
|
38
|
+
* Only `confirmed` and `junk` — the two dispositions that represent a POSITIVE
|
|
39
|
+
* ACT by the operator. `unknown` is never promoted under any flag, because
|
|
40
|
+
* "nobody has acted yet" is not evidence in either direction, and folding it as
|
|
41
|
+
* good is the trust-by-neglect defect closed four times already in this
|
|
42
|
+
* subsystem (#1064, #1318/#1319, #1320, #1322).
|
|
43
|
+
*
|
|
44
|
+
* ## `origin: "ingester"`, never `"manual"`
|
|
45
|
+
*
|
|
46
|
+
* `OutcomeStore` treats a `"manual"` disposition as STICKY against later
|
|
47
|
+
* ingester writes, because it represents a human's explicit judgment. Nothing
|
|
48
|
+
* here is that: it is an automated grade derived from an HTTP response. Marking
|
|
49
|
+
* these `"manual"` would let an automated observation permanently override an
|
|
50
|
+
* operator who had explicitly ruled the other way — precisely inverting the
|
|
51
|
+
* precedence the field exists to protect.
|
|
52
|
+
*/
|
|
53
|
+
/** Feature flag. Absent or anything but a truthy value ⇒ promotion refuses. */
|
|
54
|
+
export declare const BUTLER_PROMOTE_FLAG = "PATCHWORK_FLAG_BUTLER_PROMOTE";
|
|
55
|
+
export interface PromoteOptions {
|
|
56
|
+
/** `~/.patchwork` override (tests). */
|
|
57
|
+
patchworkDir: string;
|
|
58
|
+
/** Clock, injected so a written record is reproducible in tests. */
|
|
59
|
+
now?: number;
|
|
60
|
+
/** Report only; write nothing. Independent of the flag. */
|
|
61
|
+
dryRun?: boolean;
|
|
62
|
+
/** Flag override, injected rather than read from the environment in tests. */
|
|
63
|
+
enabled?: boolean;
|
|
64
|
+
}
|
|
65
|
+
export interface PromoteResult {
|
|
66
|
+
/** Every graded row considered — the DENOMINATOR, always reported. */
|
|
67
|
+
rows: number;
|
|
68
|
+
/** Rows whose disposition is `confirmed` or `junk`. */
|
|
69
|
+
promotable: number;
|
|
70
|
+
/** Rows withheld because the grader said `unknown`. */
|
|
71
|
+
withheld: number;
|
|
72
|
+
/** Rows written to the outcome log. Zero on a dry run or with the flag off. */
|
|
73
|
+
promoted: number;
|
|
74
|
+
/** Rows already carrying the same disposition in the trust ledger. */
|
|
75
|
+
alreadyRecorded: number;
|
|
76
|
+
/** Rows whose stored ref could not be turned back into a key, with why. */
|
|
77
|
+
unkeyable: {
|
|
78
|
+
ref: string;
|
|
79
|
+
reason: string;
|
|
80
|
+
}[];
|
|
81
|
+
/** True when nothing was written because the flag is off. */
|
|
82
|
+
blockedByFlag: boolean;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Split a stored `"<tool>:<id>"` key back into its parts.
|
|
86
|
+
*
|
|
87
|
+
* On the FIRST colon, which is the inverse of how `canonicalActionRef` joins
|
|
88
|
+
* them. Tool ids in this repo are dot-separated (`todoist.create_task`) and
|
|
89
|
+
* never contain a colon, while connector ids routinely do — so splitting on the
|
|
90
|
+
* last colon, or on every colon, would silently rekey the action and attach a
|
|
91
|
+
* confirmation to nothing.
|
|
92
|
+
*
|
|
93
|
+
* A URL-shaped key is refused rather than split. Those belong to the legacy
|
|
94
|
+
* `issueUrl` namespace, which `canonicalActionRef` explicitly refuses to
|
|
95
|
+
* produce; treating one as a `ref` here would write a row under a key no reader
|
|
96
|
+
* looks for.
|
|
97
|
+
*/
|
|
98
|
+
export declare function splitStoredRef(stored: string): {
|
|
99
|
+
tool: string;
|
|
100
|
+
id: string;
|
|
101
|
+
} | {
|
|
102
|
+
error: string;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Fold graded shadow rows into the trust ledger.
|
|
106
|
+
*
|
|
107
|
+
* Idempotent: a row whose disposition already matches what the ledger holds is
|
|
108
|
+
* counted as `alreadyRecorded` and NOT rewritten. `upsert` appends, so
|
|
109
|
+
* re-running without this would grow the file the autonomy gate reads with rows
|
|
110
|
+
* that say nothing new — and that file's byte cap is already what starves trust
|
|
111
|
+
* evidence (#1337).
|
|
112
|
+
*/
|
|
113
|
+
export declare function promoteShadowOutcomes(opts: PromoteOptions): PromoteResult;
|
|
114
|
+
/**
|
|
115
|
+
* Render the result.
|
|
116
|
+
*
|
|
117
|
+
* Leads with the denominator and never prints a bare promoted count, for the
|
|
118
|
+
* same reason the privacy shadow report refuses to: "3 promoted" reads as a
|
|
119
|
+
* measure of the channel's health when it partly measures how little was
|
|
120
|
+
* observed.
|
|
121
|
+
*/
|
|
122
|
+
export declare function formatPromoteResult(r: PromoteResult): string;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promote graded Butler shadow rows into the trust ledger — flag-gated, OFF.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a separate module and not a branch in the ingester
|
|
5
|
+
*
|
|
6
|
+
* `outcomeIngester.ts` and `errandOutcomeGrader.ts` each carry a test asserting
|
|
7
|
+
* they cannot reach `OutcomeStore` — not "do not call it", but *do not import
|
|
8
|
+
* anything that could*. That is what makes "shadow-only" a structural fact
|
|
9
|
+
* rather than a promise, and it is why a reviewer can read the grader without
|
|
10
|
+
* checking whether some path in it writes evidence.
|
|
11
|
+
*
|
|
12
|
+
* Adding promotion there would have meant deleting those guards. This module
|
|
13
|
+
* exists so they stay exactly as they are: the grader stays a pure function of
|
|
14
|
+
* observed state, the ingester stays unable to write trust, and the single
|
|
15
|
+
* place that can is this one, which does nothing else.
|
|
16
|
+
*
|
|
17
|
+
* ## Operator path only
|
|
18
|
+
*
|
|
19
|
+
* Not registered as a recipe tool, and a test asserts it. A recipe step runs AS
|
|
20
|
+
* the worker, so a worker able to reach this could promote its own filings —
|
|
21
|
+
* manufacturing the evidence that raises its own dial. Same reasoning as
|
|
22
|
+
* `outcomes confirm|reject`, which is also operator-only for exactly this.
|
|
23
|
+
*
|
|
24
|
+
* ## Flag-gated OFF, and the flag is about EVIDENCE, not about code
|
|
25
|
+
*
|
|
26
|
+
* Promotion is one-way in the way that matters: once a row is folded, the dial
|
|
27
|
+
* it moved cannot be un-moved by deleting the row, because trust replay has
|
|
28
|
+
* already absorbed it into a checkpoint. So the gate is not "is the code
|
|
29
|
+
* finished" — it is "is there enough evidence to start counting this channel".
|
|
30
|
+
*
|
|
31
|
+
* At the time of writing the shadow ledger holds ONE confirmed row. That is a
|
|
32
|
+
* real positive act, correctly attributed, and it is one row. The flag exists so
|
|
33
|
+
* the code can be reviewed, tested and merged now, and the trust decision made
|
|
34
|
+
* later on more of them, by a person, deliberately.
|
|
35
|
+
*
|
|
36
|
+
* ## What is promoted, and what is refused
|
|
37
|
+
*
|
|
38
|
+
* Only `confirmed` and `junk` — the two dispositions that represent a POSITIVE
|
|
39
|
+
* ACT by the operator. `unknown` is never promoted under any flag, because
|
|
40
|
+
* "nobody has acted yet" is not evidence in either direction, and folding it as
|
|
41
|
+
* good is the trust-by-neglect defect closed four times already in this
|
|
42
|
+
* subsystem (#1064, #1318/#1319, #1320, #1322).
|
|
43
|
+
*
|
|
44
|
+
* ## `origin: "ingester"`, never `"manual"`
|
|
45
|
+
*
|
|
46
|
+
* `OutcomeStore` treats a `"manual"` disposition as STICKY against later
|
|
47
|
+
* ingester writes, because it represents a human's explicit judgment. Nothing
|
|
48
|
+
* here is that: it is an automated grade derived from an HTTP response. Marking
|
|
49
|
+
* these `"manual"` would let an automated observation permanently override an
|
|
50
|
+
* operator who had explicitly ruled the other way — precisely inverting the
|
|
51
|
+
* precedence the field exists to protect.
|
|
52
|
+
*/
|
|
53
|
+
import { OutcomeStore } from "../workers/outcomeStore.js";
|
|
54
|
+
import { readShadowRows, wouldCountAsEvidence, } from "./outcomeShadowLog.js";
|
|
55
|
+
/** Feature flag. Absent or anything but a truthy value ⇒ promotion refuses. */
|
|
56
|
+
export const BUTLER_PROMOTE_FLAG = "PATCHWORK_FLAG_BUTLER_PROMOTE";
|
|
57
|
+
function flagEnabled(opts) {
|
|
58
|
+
if (opts.enabled !== undefined)
|
|
59
|
+
return opts.enabled;
|
|
60
|
+
const v = process.env[BUTLER_PROMOTE_FLAG];
|
|
61
|
+
return v === "1" || v?.toLowerCase() === "true";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Split a stored `"<tool>:<id>"` key back into its parts.
|
|
65
|
+
*
|
|
66
|
+
* On the FIRST colon, which is the inverse of how `canonicalActionRef` joins
|
|
67
|
+
* them. Tool ids in this repo are dot-separated (`todoist.create_task`) and
|
|
68
|
+
* never contain a colon, while connector ids routinely do — so splitting on the
|
|
69
|
+
* last colon, or on every colon, would silently rekey the action and attach a
|
|
70
|
+
* confirmation to nothing.
|
|
71
|
+
*
|
|
72
|
+
* A URL-shaped key is refused rather than split. Those belong to the legacy
|
|
73
|
+
* `issueUrl` namespace, which `canonicalActionRef` explicitly refuses to
|
|
74
|
+
* produce; treating one as a `ref` here would write a row under a key no reader
|
|
75
|
+
* looks for.
|
|
76
|
+
*/
|
|
77
|
+
export function splitStoredRef(stored) {
|
|
78
|
+
if (/^https?:\/\//i.test(stored)) {
|
|
79
|
+
return {
|
|
80
|
+
error: "URL-shaped key belongs to the legacy issueUrl namespace, not the tool/id one",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const i = stored.indexOf(":");
|
|
84
|
+
if (i <= 0 || i === stored.length - 1) {
|
|
85
|
+
return { error: "not in '<tool>:<id>' form" };
|
|
86
|
+
}
|
|
87
|
+
return { tool: stored.slice(0, i), id: stored.slice(i + 1) };
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fold graded shadow rows into the trust ledger.
|
|
91
|
+
*
|
|
92
|
+
* Idempotent: a row whose disposition already matches what the ledger holds is
|
|
93
|
+
* counted as `alreadyRecorded` and NOT rewritten. `upsert` appends, so
|
|
94
|
+
* re-running without this would grow the file the autonomy gate reads with rows
|
|
95
|
+
* that say nothing new — and that file's byte cap is already what starves trust
|
|
96
|
+
* evidence (#1337).
|
|
97
|
+
*/
|
|
98
|
+
export function promoteShadowOutcomes(opts) {
|
|
99
|
+
const rows = readShadowRows(opts.patchworkDir);
|
|
100
|
+
const enabled = flagEnabled(opts);
|
|
101
|
+
const result = {
|
|
102
|
+
rows: rows.length,
|
|
103
|
+
promotable: 0,
|
|
104
|
+
withheld: 0,
|
|
105
|
+
promoted: 0,
|
|
106
|
+
alreadyRecorded: 0,
|
|
107
|
+
unkeyable: [],
|
|
108
|
+
blockedByFlag: !enabled,
|
|
109
|
+
};
|
|
110
|
+
const store = new OutcomeStore(opts.patchworkDir);
|
|
111
|
+
const now = opts.now ?? Date.now();
|
|
112
|
+
// Last grade wins per ref. The ledger is append-only and an errand is
|
|
113
|
+
// observed repeatedly, so the same ref legitimately appears many times —
|
|
114
|
+
// promoting each one would write the same fact over and over, and an older
|
|
115
|
+
// grade could land after a newer one.
|
|
116
|
+
const latest = new Map();
|
|
117
|
+
for (const row of rows) {
|
|
118
|
+
const prev = latest.get(row.ref);
|
|
119
|
+
if (!prev || row.gradedAt >= prev.gradedAt)
|
|
120
|
+
latest.set(row.ref, row);
|
|
121
|
+
}
|
|
122
|
+
for (const row of latest.values()) {
|
|
123
|
+
if (!wouldCountAsEvidence(row.disposition)) {
|
|
124
|
+
result.withheld++;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
result.promotable++;
|
|
128
|
+
const parts = splitStoredRef(row.ref);
|
|
129
|
+
if ("error" in parts) {
|
|
130
|
+
// Reported, never dropped. A row that cannot be keyed is a measurement
|
|
131
|
+
// gap, and a run that silently skipped some looks identical to a clean one.
|
|
132
|
+
result.unkeyable.push({ ref: row.ref, reason: parts.error });
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (store.getDispositionForRef(parts) === row.disposition) {
|
|
136
|
+
result.alreadyRecorded++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!enabled || opts.dryRun)
|
|
140
|
+
continue;
|
|
141
|
+
store.upsert({
|
|
142
|
+
ref: parts,
|
|
143
|
+
disposition: row.disposition,
|
|
144
|
+
checkedAt: now,
|
|
145
|
+
...(row.recipe ? { recipeName: row.recipe } : {}),
|
|
146
|
+
// NOT "manual" — see the header. This is an automated grade, and marking
|
|
147
|
+
// it manual would make it sticky against a human who ruled otherwise.
|
|
148
|
+
origin: "ingester",
|
|
149
|
+
});
|
|
150
|
+
result.promoted++;
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Render the result.
|
|
156
|
+
*
|
|
157
|
+
* Leads with the denominator and never prints a bare promoted count, for the
|
|
158
|
+
* same reason the privacy shadow report refuses to: "3 promoted" reads as a
|
|
159
|
+
* measure of the channel's health when it partly measures how little was
|
|
160
|
+
* observed.
|
|
161
|
+
*/
|
|
162
|
+
export function formatPromoteResult(r) {
|
|
163
|
+
const lines = [];
|
|
164
|
+
lines.push(`[butler-promote] ${r.rows} graded row(s) in the shadow ledger`);
|
|
165
|
+
lines.push(` ${r.promotable} promotable (confirmed or junk) · ${r.withheld} withheld as unknown`);
|
|
166
|
+
if (r.alreadyRecorded > 0) {
|
|
167
|
+
lines.push(` ${r.alreadyRecorded} already recorded with the same verdict`);
|
|
168
|
+
}
|
|
169
|
+
for (const u of r.unkeyable) {
|
|
170
|
+
lines.push(` unkeyable ${u.ref}: ${u.reason}`);
|
|
171
|
+
}
|
|
172
|
+
lines.push("");
|
|
173
|
+
if (r.blockedByFlag) {
|
|
174
|
+
lines.push(` NOTHING WAS WRITTEN — ${BUTLER_PROMOTE_FLAG} is not set, so this run`);
|
|
175
|
+
lines.push(" was a report. Promotion moves the trust dial and a folded");
|
|
176
|
+
lines.push(" row cannot be un-folded by deleting it, so it is off until");
|
|
177
|
+
lines.push(" a person turns it on against evidence they have read.");
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
lines.push(` ${r.promoted} row(s) written to the trust ledger.`);
|
|
181
|
+
}
|
|
182
|
+
return `${lines.join("\n")}\n`;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=promoteShadowOutcomes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"promoteShadowOutcomes.js","sourceRoot":"","sources":["../../src/butler/promoteShadowOutcomes.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Todoist connector — manage tasks and projects via the Todoist
|
|
2
|
+
* Todoist connector — manage tasks and projects via the Todoist unified API v1.
|
|
3
|
+
*
|
|
4
|
+
* REST v2 answers 410 Gone. The base URL moved to v1; the response interfaces
|
|
5
|
+
* below moved with it on 2026-08-19, nine days later — see `TodoistTask`.
|
|
3
6
|
*
|
|
4
7
|
* Auth: API token (personal or app token).
|
|
5
8
|
* - Env var: TODOIST_API_KEY overrides stored token for CI/headless use.
|
|
@@ -24,6 +27,31 @@ export interface TodoistDue {
|
|
|
24
27
|
datetime?: string;
|
|
25
28
|
timezone?: string;
|
|
26
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* A task as the v1 API sends it.
|
|
32
|
+
*
|
|
33
|
+
* These names are v1's, not REST v2's, and the difference is not cosmetic. The
|
|
34
|
+
* base URL moved to v1 when v2 started answering 410 Gone; this interface did
|
|
35
|
+
* not move with it, so for nine days it declared EIGHT fields the wire never
|
|
36
|
+
* sent. Two of them were read to make a decision:
|
|
37
|
+
*
|
|
38
|
+
* `is_completed` → v1 sends `checked` (plus `completed_at`)
|
|
39
|
+
* `created_at` → v1 sends `added_at`
|
|
40
|
+
*
|
|
41
|
+
* `observeTask` read both, so a Butler errand the operator had genuinely
|
|
42
|
+
* completed graded `unknown` / `open-recent` and no filing could ever earn
|
|
43
|
+
* trust. A blind `res.json() as Promise<TodoistTask>` cast reports nothing when
|
|
44
|
+
* it is wrong: the fields simply arrive `undefined`.
|
|
45
|
+
*
|
|
46
|
+
* The other six — `url`, `order`, `comment_count`, `creator_id`, `assignee_id`,
|
|
47
|
+
* `assigner_id` — are removed rather than renamed where v1 has no counterpart.
|
|
48
|
+
* `url` in particular never existed on v1: Todoist exposes no task permalink,
|
|
49
|
+
* which is why the outcome join key had to be generalised to `<tool>:<id>`.
|
|
50
|
+
*
|
|
51
|
+
* Key set captured from the live API on 2026-08-19; the shared test fixture
|
|
52
|
+
* (`__tests__/todoistV1Fixture.ts`) carries the same set and is asserted
|
|
53
|
+
* against it.
|
|
54
|
+
*/
|
|
27
55
|
export interface TodoistTask {
|
|
28
56
|
id: string;
|
|
29
57
|
content: string;
|
|
@@ -31,30 +59,73 @@ export interface TodoistTask {
|
|
|
31
59
|
project_id: string;
|
|
32
60
|
section_id: string | null;
|
|
33
61
|
parent_id: string | null;
|
|
34
|
-
order
|
|
62
|
+
/** v1's ordering field. Was declared `order`, which v1 does not send. */
|
|
63
|
+
child_order: number;
|
|
64
|
+
day_order: number;
|
|
35
65
|
priority: number;
|
|
36
66
|
due: TodoistDue | null;
|
|
67
|
+
deadline: unknown | null;
|
|
68
|
+
duration: unknown | null;
|
|
37
69
|
labels: string[];
|
|
38
|
-
is_completed
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
70
|
+
/** Completion flag. Was declared `is_completed`, which v1 does not send. */
|
|
71
|
+
checked: boolean;
|
|
72
|
+
completed_at: string | null;
|
|
73
|
+
completed_by_uid: string | null;
|
|
74
|
+
completed_count: number;
|
|
75
|
+
/** Creation stamp. Was declared `created_at`, which v1 does not send. */
|
|
76
|
+
added_at: string;
|
|
77
|
+
added_by_uid: string | null;
|
|
78
|
+
updated_at: string;
|
|
79
|
+
assigned_by_uid?: string | null;
|
|
80
|
+
responsible_uid?: string | null;
|
|
81
|
+
user_id: string;
|
|
82
|
+
note_count: number;
|
|
83
|
+
postponed_count: number;
|
|
84
|
+
is_collapsed: boolean;
|
|
85
|
+
is_deleted: boolean;
|
|
45
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* A project as the v1 API sends it.
|
|
89
|
+
*
|
|
90
|
+
* Same migration, same miss: `order` is `child_order`, `is_inbox_project` is
|
|
91
|
+
* `inbox_project`, `is_team_inbox` does not exist, and there is no `url`.
|
|
92
|
+
*
|
|
93
|
+
* Note the asymmetry with `TodoistTask`, which is real rather than a
|
|
94
|
+
* transcription slip: projects DO carry `created_at`; tasks carry `added_at`.
|
|
95
|
+
* That is a large part of why the task interface's `created_at` looked right.
|
|
96
|
+
*/
|
|
46
97
|
export interface TodoistProject {
|
|
47
98
|
id: string;
|
|
48
99
|
name: string;
|
|
49
100
|
color: string;
|
|
101
|
+
description: string;
|
|
50
102
|
parent_id: string | null;
|
|
51
|
-
|
|
103
|
+
child_order: number;
|
|
104
|
+
default_order: number;
|
|
105
|
+
order_key: string;
|
|
52
106
|
is_favorite: boolean;
|
|
53
|
-
|
|
54
|
-
is_team_inbox: boolean;
|
|
107
|
+
inbox_project: boolean;
|
|
55
108
|
is_shared: boolean;
|
|
56
|
-
|
|
109
|
+
is_archived: boolean;
|
|
110
|
+
is_collapsed: boolean;
|
|
111
|
+
is_deleted: boolean;
|
|
112
|
+
is_frozen: boolean;
|
|
113
|
+
can_assign_tasks: boolean;
|
|
114
|
+
can_comment: boolean;
|
|
115
|
+
view_style: string;
|
|
116
|
+
created_at: string;
|
|
117
|
+
updated_at: string;
|
|
118
|
+
creator_uid: string;
|
|
57
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* A label as the v1 API sends it.
|
|
122
|
+
*
|
|
123
|
+
* DELIBERATELY LEFT AS DECLARED. The account used to capture the task and
|
|
124
|
+
* project shapes has no labels, so `GET /labels` returned an empty list and
|
|
125
|
+
* there is no observed item shape to correct this against. Rewriting it from
|
|
126
|
+
* the pattern of its siblings would be a guess wearing the same clothes as the
|
|
127
|
+
* measurements above, and this file is a demonstration of what that costs.
|
|
128
|
+
*/
|
|
58
129
|
export interface TodoistLabel {
|
|
59
130
|
id: string;
|
|
60
131
|
name: string;
|
|
@@ -123,10 +194,26 @@ export declare class TodoistConnector extends BaseConnector {
|
|
|
123
194
|
* Additive on purpose. Changing `getTask` would alter behaviour for every
|
|
124
195
|
* existing caller to serve one new one.
|
|
125
196
|
*/
|
|
197
|
+
/**
|
|
198
|
+
* Read one task's current state for the Butler observation channel.
|
|
199
|
+
*
|
|
200
|
+
* `createdAt` is OPTIONAL, and that is the fix for the subtler half of the
|
|
201
|
+
* v1 field mismatch. It previously read `created_at` — absent on v1 — so
|
|
202
|
+
* `Date.parse` returned NaN and the guard below substituted `Date.now()`.
|
|
203
|
+
* The guard is right that 0 would be read as 1970 and graded `junk`; it was
|
|
204
|
+
* wrong to answer with a fabricated timestamp instead, because "created just
|
|
205
|
+
* now" is what the staleness horizon measures, and refreshing it on every
|
|
206
|
+
* run put `stale-unactioned` permanently out of reach. Silently: the channel
|
|
207
|
+
* reported a clean observation throughout.
|
|
208
|
+
*
|
|
209
|
+
* Omitting it is the honest third answer. The grader checks `completed`
|
|
210
|
+
* FIRST, so a real completion still confirms; only the age-based branch
|
|
211
|
+
* withholds, which is exactly what "we could not read its age" means.
|
|
212
|
+
*/
|
|
126
213
|
observeTask(id: string): Promise<{
|
|
127
214
|
kind: "observed";
|
|
128
215
|
completed: boolean;
|
|
129
|
-
createdAt
|
|
216
|
+
createdAt?: number;
|
|
130
217
|
} | {
|
|
131
218
|
kind: "deleted";
|
|
132
219
|
} | {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Todoist connector — manage tasks and projects via the Todoist
|
|
2
|
+
* Todoist connector — manage tasks and projects via the Todoist unified API v1.
|
|
3
|
+
*
|
|
4
|
+
* REST v2 answers 410 Gone. The base URL moved to v1; the response interfaces
|
|
5
|
+
* below moved with it on 2026-08-19, nine days later — see `TodoistTask`.
|
|
3
6
|
*
|
|
4
7
|
* Auth: API token (personal or app token).
|
|
5
8
|
* - Env var: TODOIST_API_KEY overrides stored token for CI/headless use.
|
|
@@ -246,6 +249,22 @@ export class TodoistConnector extends BaseConnector {
|
|
|
246
249
|
* Additive on purpose. Changing `getTask` would alter behaviour for every
|
|
247
250
|
* existing caller to serve one new one.
|
|
248
251
|
*/
|
|
252
|
+
/**
|
|
253
|
+
* Read one task's current state for the Butler observation channel.
|
|
254
|
+
*
|
|
255
|
+
* `createdAt` is OPTIONAL, and that is the fix for the subtler half of the
|
|
256
|
+
* v1 field mismatch. It previously read `created_at` — absent on v1 — so
|
|
257
|
+
* `Date.parse` returned NaN and the guard below substituted `Date.now()`.
|
|
258
|
+
* The guard is right that 0 would be read as 1970 and graded `junk`; it was
|
|
259
|
+
* wrong to answer with a fabricated timestamp instead, because "created just
|
|
260
|
+
* now" is what the staleness horizon measures, and refreshing it on every
|
|
261
|
+
* run put `stale-unactioned` permanently out of reach. Silently: the channel
|
|
262
|
+
* reported a clean observation throughout.
|
|
263
|
+
*
|
|
264
|
+
* Omitting it is the honest third answer. The grader checks `completed`
|
|
265
|
+
* FIRST, so a real completion still confirms; only the age-based branch
|
|
266
|
+
* withholds, which is exactly what "we could not read its age" means.
|
|
267
|
+
*/
|
|
249
268
|
async observeTask(id) {
|
|
250
269
|
const result = await this.apiCall(async (token) => {
|
|
251
270
|
const res = await fetch(`${TODOIST_BASE}/tasks/${id}`, {
|
|
@@ -263,13 +282,15 @@ export class TodoistConnector extends BaseConnector {
|
|
|
263
282
|
return { kind: "deleted" };
|
|
264
283
|
return { kind: "unavailable", reason: result.error.code };
|
|
265
284
|
}
|
|
266
|
-
const createdAt = Date.parse(result.data.
|
|
285
|
+
const createdAt = Date.parse(result.data.added_at);
|
|
267
286
|
return {
|
|
268
287
|
kind: "observed",
|
|
269
|
-
completed: result.data.
|
|
270
|
-
// An unparseable
|
|
271
|
-
//
|
|
272
|
-
|
|
288
|
+
completed: result.data.checked === true,
|
|
289
|
+
// An unparseable stamp yields NO age rather than a made-up one. Zero
|
|
290
|
+
// reads as 1970 and grades `junk` — a negative manufactured from a parse
|
|
291
|
+
// failure. `Date.now()` reads as brand new, which is what hid the v1
|
|
292
|
+
// field rename for nine days. Neither is an observation of age.
|
|
293
|
+
...(Number.isFinite(createdAt) ? { createdAt } : {}),
|
|
273
294
|
};
|
|
274
295
|
}
|
|
275
296
|
async createTask(content, projectId, description, dueString, priority, labels) {
|
|
@@ -431,7 +452,11 @@ export class TodoistConnector extends BaseConnector {
|
|
|
431
452
|
status: res.status,
|
|
432
453
|
});
|
|
433
454
|
}
|
|
434
|
-
|
|
455
|
+
// `unwrapList`, like its siblings. This was the one list endpoint the v1
|
|
456
|
+
// migration missed, so it returned the raw `{ results, next_cursor }`
|
|
457
|
+
// envelope typed as an array: `.length` undefined, `.map` a TypeError.
|
|
458
|
+
// Latent rather than live only because nothing calls it yet.
|
|
459
|
+
return unwrapList(await res.json());
|
|
435
460
|
});
|
|
436
461
|
if ("error" in result)
|
|
437
462
|
throw new Error(result.error.message);
|