@tt-a1i/openpi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resume journal: cache agent results by call content so a re-run can replay
|
|
3
|
+
* the calls that did not change.
|
|
4
|
+
*
|
|
5
|
+
* Keying is by content, NOT by call ordinal, and that is load-bearing.
|
|
6
|
+
* `pipeline()` deliberately has no barrier between stages, so the order in
|
|
7
|
+
* which agent calls are issued depends on how long each agent actually took:
|
|
8
|
+
*
|
|
9
|
+
* stage1 latencies {a:40,b:5,c:20} -> s1:a#1 s1:b#2 s1:c#3 s2:b#4 s2:c#5 s2:a#6
|
|
10
|
+
* stage1 latencies {a:5,b:40,c:20} -> s1:a#1 s1:b#2 s1:c#3 s2:a#4 s2:c#5 s2:b#6
|
|
11
|
+
*
|
|
12
|
+
* Same script, same args, but #4 is a different call each time. Replaying by
|
|
13
|
+
* ordinal would hand one item's cached result to another — a silently wrong
|
|
14
|
+
* answer, which is worse than no resume at all. Hashing the content makes
|
|
15
|
+
* replay order-independent.
|
|
16
|
+
*
|
|
17
|
+
* It also means non-determinism in a script (`Date.now()` in a prompt) can only
|
|
18
|
+
* cost cache hits, never correctness: a changed prompt simply misses.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
|
|
23
|
+
/** Cap on the whole journal artifact; oldest entries are dropped first. */
|
|
24
|
+
export const JOURNAL_MAX_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
// Version 1 did not record side-effect eligibility or cwd/resource identity.
|
|
26
|
+
// Reject it rather than risk replaying an old write-capable call.
|
|
27
|
+
export const JOURNAL_VERSION = 2;
|
|
28
|
+
|
|
29
|
+
/** The replay payload: exactly the fields a script observes from agent(). */
|
|
30
|
+
export interface JournalEntry {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly output: string;
|
|
33
|
+
readonly structured?: unknown;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface WorkflowJournal {
|
|
37
|
+
readonly version: number;
|
|
38
|
+
readonly entries: readonly JournalEntry[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Only semantic options change what an agent produces, so only these are
|
|
43
|
+
* hashed. Callers pass their whole options object; `label` and `phase` are
|
|
44
|
+
* display only, and renaming a label must not invalidate an identical call.
|
|
45
|
+
*/
|
|
46
|
+
interface KeyedCallOptions {
|
|
47
|
+
/** Canonical model/effort and resolved Agent Type semantics. */
|
|
48
|
+
readonly execution?: unknown;
|
|
49
|
+
readonly schema?: unknown;
|
|
50
|
+
readonly model?: unknown;
|
|
51
|
+
readonly provider?: unknown;
|
|
52
|
+
readonly effort?: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Content hash identifying one agent call for replay. */
|
|
56
|
+
export function agentCallKey(
|
|
57
|
+
prompt: string,
|
|
58
|
+
options: Readonly<KeyedCallOptions> = {},
|
|
59
|
+
) {
|
|
60
|
+
const canonical = options.execution !== undefined;
|
|
61
|
+
const payload = stableSerialize({
|
|
62
|
+
prompt,
|
|
63
|
+
execution: options.execution,
|
|
64
|
+
schema: options.schema,
|
|
65
|
+
// Callers without a canonical execution snapshot retain the legacy key.
|
|
66
|
+
model:
|
|
67
|
+
!canonical && typeof options.model === "string"
|
|
68
|
+
? options.model
|
|
69
|
+
: undefined,
|
|
70
|
+
provider:
|
|
71
|
+
!canonical && typeof options.provider === "string"
|
|
72
|
+
? options.provider
|
|
73
|
+
: undefined,
|
|
74
|
+
effort:
|
|
75
|
+
!canonical && typeof options.effort === "string"
|
|
76
|
+
? options.effort
|
|
77
|
+
: undefined,
|
|
78
|
+
});
|
|
79
|
+
return createHash("sha256").update(payload).digest("hex").slice(0, 32);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stableSerialize(value: unknown): string {
|
|
83
|
+
if (value === undefined) return "undefined";
|
|
84
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
85
|
+
if (Array.isArray(value)) {
|
|
86
|
+
return `[${value.map(stableSerialize).join(",")}]`;
|
|
87
|
+
}
|
|
88
|
+
// Key order must not affect the hash: two schemas that differ only in
|
|
89
|
+
// property order are the same schema.
|
|
90
|
+
const record = value as Record<string, unknown>;
|
|
91
|
+
const parts = Object.keys(record)
|
|
92
|
+
.sort()
|
|
93
|
+
.filter((key) => record[key] !== undefined)
|
|
94
|
+
.map((key) => `${JSON.stringify(key)}:${stableSerialize(record[key])}`);
|
|
95
|
+
return `{${parts.join(",")}}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Replay lookup over a prior run's journal. A key can legitimately repeat (a
|
|
100
|
+
* script calling the same prompt twice), so each key keeps its own cursor and
|
|
101
|
+
* the Nth occurrence consumes the Nth recorded result.
|
|
102
|
+
*/
|
|
103
|
+
export function createReplayCache(journal: WorkflowJournal | undefined) {
|
|
104
|
+
const byKey = new Map<string, JournalEntry[]>();
|
|
105
|
+
for (const entry of journal?.entries ?? []) {
|
|
106
|
+
const bucket = byKey.get(entry.key);
|
|
107
|
+
if (bucket) bucket.push(entry);
|
|
108
|
+
else byKey.set(entry.key, [entry]);
|
|
109
|
+
}
|
|
110
|
+
const cursors = new Map<string, number>();
|
|
111
|
+
let replayed = 0;
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
/** Consume the next cached result for this key, or undefined on a miss. */
|
|
115
|
+
take(key: string) {
|
|
116
|
+
const bucket = byKey.get(key);
|
|
117
|
+
if (!bucket) return undefined;
|
|
118
|
+
const cursor = cursors.get(key) ?? 0;
|
|
119
|
+
const entry = bucket[cursor];
|
|
120
|
+
if (!entry) return undefined;
|
|
121
|
+
cursors.set(key, cursor + 1);
|
|
122
|
+
replayed++;
|
|
123
|
+
return entry;
|
|
124
|
+
},
|
|
125
|
+
/** How many calls this run has replayed so far. */
|
|
126
|
+
get replayed() {
|
|
127
|
+
return replayed;
|
|
128
|
+
},
|
|
129
|
+
/** Entries available from the source run. */
|
|
130
|
+
get available() {
|
|
131
|
+
return journal?.entries.length ?? 0;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type ReplayCache = ReturnType<typeof createReplayCache>;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Trim to the artifact cap by dropping the OLDEST entries, so a long run keeps
|
|
140
|
+
* the results a re-run is most likely to still want. Reports what was dropped:
|
|
141
|
+
* silent truncation would read as a naturally poor hit rate.
|
|
142
|
+
*
|
|
143
|
+
* Sized with plain JSON.stringify, NOT safeStringify: the latter replaces an
|
|
144
|
+
* over-cap value with a `{truncated, preview}` stub, which is always small, so
|
|
145
|
+
* measuring with it would always look under budget and then hand the writer a
|
|
146
|
+
* journal that serializes to a useless preview string.
|
|
147
|
+
*/
|
|
148
|
+
export function boundedJournal(entries: readonly JournalEntry[]) {
|
|
149
|
+
const size = (kept: readonly JournalEntry[]) =>
|
|
150
|
+
Buffer.byteLength(
|
|
151
|
+
JSON.stringify({ version: JOURNAL_VERSION, entries: kept }, null, 2),
|
|
152
|
+
"utf8",
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
let kept = [...entries];
|
|
156
|
+
let dropped = 0;
|
|
157
|
+
while (kept.length > 0 && size(kept) > JOURNAL_MAX_BYTES) {
|
|
158
|
+
kept = kept.slice(1);
|
|
159
|
+
dropped++;
|
|
160
|
+
}
|
|
161
|
+
return { journal: { version: JOURNAL_VERSION, entries: kept }, dropped };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
165
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Lenient parse: resume is an optimization, so anything unreadable degrades to
|
|
170
|
+
* "no cache" rather than failing the run.
|
|
171
|
+
*/
|
|
172
|
+
export function parseJournal(value: unknown): WorkflowJournal | undefined {
|
|
173
|
+
if (!isRecord(value)) return undefined;
|
|
174
|
+
if (value.version !== JOURNAL_VERSION) return undefined;
|
|
175
|
+
if (!Array.isArray(value.entries)) return undefined;
|
|
176
|
+
const entries: JournalEntry[] = [];
|
|
177
|
+
for (const raw of value.entries) {
|
|
178
|
+
if (!isRecord(raw)) continue;
|
|
179
|
+
if (typeof raw.key !== "string" || !raw.key) continue;
|
|
180
|
+
if (typeof raw.output !== "string") continue;
|
|
181
|
+
entries.push({
|
|
182
|
+
key: raw.key,
|
|
183
|
+
output: raw.output,
|
|
184
|
+
...(raw.structured !== undefined ? { structured: raw.structured } : {}),
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return { version: JOURNAL_VERSION, entries };
|
|
188
|
+
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import {
|
|
2
|
+
parse,
|
|
3
|
+
type ArrayExpression,
|
|
4
|
+
type ExportNamedDeclaration,
|
|
5
|
+
type Expression,
|
|
6
|
+
type Identifier,
|
|
7
|
+
type Literal,
|
|
8
|
+
type ObjectExpression,
|
|
9
|
+
type Program,
|
|
10
|
+
type Property,
|
|
11
|
+
type VariableDeclaration,
|
|
12
|
+
} from "acorn";
|
|
13
|
+
import { sanitizeTerminalText } from "../shared/terminal-text.ts";
|
|
14
|
+
|
|
15
|
+
/** Static workflow metadata and source preparation helpers. */
|
|
16
|
+
|
|
17
|
+
export interface WorkflowPhase {
|
|
18
|
+
title: string;
|
|
19
|
+
detail?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface WorkflowMeta {
|
|
23
|
+
name?: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
phases: WorkflowPhase[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PreparedWorkflowScript {
|
|
29
|
+
/** Script body with the metadata declaration removed. */
|
|
30
|
+
source: string;
|
|
31
|
+
meta: WorkflowMeta;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseProgram(source: string): Program {
|
|
35
|
+
return parse(source, {
|
|
36
|
+
ecmaVersion: "latest",
|
|
37
|
+
sourceType: "module",
|
|
38
|
+
allowReturnOutsideFunction: true,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isIdentifier(node: Expression, name?: string): node is Identifier {
|
|
43
|
+
return (
|
|
44
|
+
node.type === "Identifier" && (name === undefined || node.name === name)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function isLiteral(node: Expression): node is Literal {
|
|
49
|
+
return node.type === "Literal";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isProperty(
|
|
53
|
+
node: ObjectExpression["properties"][number],
|
|
54
|
+
): node is Property {
|
|
55
|
+
return node.type === "Property";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function propertyName(property: Property) {
|
|
59
|
+
if (property.computed || property.kind !== "init" || property.method) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
if (isIdentifier(property.key)) return property.key.name;
|
|
63
|
+
if (isLiteral(property.key) && typeof property.key.value === "string") {
|
|
64
|
+
return property.key.value;
|
|
65
|
+
}
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Convert an Acorn expression to data without evaluating source. Only plain
|
|
71
|
+
* object/array/primitive literals are accepted. Getters, methods, spreads,
|
|
72
|
+
* computed keys, templates, identifiers, and calls all fail closed.
|
|
73
|
+
*/
|
|
74
|
+
function literalValue(node: Expression, depth = 0): unknown {
|
|
75
|
+
if (depth > 8) throw new Error("workflow metadata is nested too deeply");
|
|
76
|
+
if (isLiteral(node)) {
|
|
77
|
+
if (
|
|
78
|
+
node.value === null ||
|
|
79
|
+
typeof node.value === "string" ||
|
|
80
|
+
typeof node.value === "number" ||
|
|
81
|
+
typeof node.value === "boolean"
|
|
82
|
+
) {
|
|
83
|
+
return node.value;
|
|
84
|
+
}
|
|
85
|
+
throw new Error("workflow metadata contains an unsupported literal");
|
|
86
|
+
}
|
|
87
|
+
if (node.type === "ArrayExpression") {
|
|
88
|
+
const arrayNode: ArrayExpression = node;
|
|
89
|
+
return arrayNode.elements.map((element) => {
|
|
90
|
+
if (!element || element.type === "SpreadElement") {
|
|
91
|
+
throw new Error(
|
|
92
|
+
"workflow metadata arrays cannot contain holes or spreads",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return literalValue(element, depth + 1);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (node.type === "ObjectExpression") {
|
|
99
|
+
const objectNode: ObjectExpression = node;
|
|
100
|
+
const value: Record<string, unknown> = Object.create(null);
|
|
101
|
+
for (const item of objectNode.properties) {
|
|
102
|
+
if (!isProperty(item)) {
|
|
103
|
+
throw new Error("workflow metadata objects cannot contain spreads");
|
|
104
|
+
}
|
|
105
|
+
const key = propertyName(item);
|
|
106
|
+
if (key === undefined || item.shorthand) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"workflow metadata keys and values must be plain literals",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
value[key] = literalValue(item.value, depth + 1);
|
|
112
|
+
}
|
|
113
|
+
return value;
|
|
114
|
+
}
|
|
115
|
+
throw new Error("workflow metadata must contain only static literals");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cleanMetaLine(value: string, maxLength: number) {
|
|
119
|
+
return sanitizeTerminalText(value)
|
|
120
|
+
.replace(/\s+/g, " ")
|
|
121
|
+
.trim()
|
|
122
|
+
.slice(0, maxLength);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sanitizeMeta(value: unknown): WorkflowMeta {
|
|
126
|
+
const meta: WorkflowMeta = { phases: [] };
|
|
127
|
+
if (!value || typeof value !== "object") return meta;
|
|
128
|
+
const raw = value as {
|
|
129
|
+
name?: unknown;
|
|
130
|
+
description?: unknown;
|
|
131
|
+
phases?: unknown;
|
|
132
|
+
};
|
|
133
|
+
if (typeof raw.name === "string") {
|
|
134
|
+
const name = cleanMetaLine(raw.name, 160);
|
|
135
|
+
if (name) meta.name = name;
|
|
136
|
+
}
|
|
137
|
+
if (typeof raw.description === "string") {
|
|
138
|
+
const description = cleanMetaLine(raw.description, 2_000);
|
|
139
|
+
if (description) meta.description = description;
|
|
140
|
+
}
|
|
141
|
+
if (Array.isArray(raw.phases)) {
|
|
142
|
+
for (const item of raw.phases.slice(0, 64)) {
|
|
143
|
+
if (!item || typeof item !== "object") continue;
|
|
144
|
+
const phase = item as { title?: unknown; detail?: unknown };
|
|
145
|
+
if (typeof phase.title !== "string") continue;
|
|
146
|
+
const title = cleanMetaLine(phase.title, 160);
|
|
147
|
+
if (!title) continue;
|
|
148
|
+
const detail =
|
|
149
|
+
typeof phase.detail === "string"
|
|
150
|
+
? cleanMetaLine(phase.detail, 2_000)
|
|
151
|
+
: "";
|
|
152
|
+
meta.phases.push({ title, ...(detail ? { detail } : {}) });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return meta;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function metadataDeclaration(statement: Program["body"][number]) {
|
|
159
|
+
if (statement.type !== "ExportNamedDeclaration") return undefined;
|
|
160
|
+
const exported: ExportNamedDeclaration = statement;
|
|
161
|
+
if (
|
|
162
|
+
exported.source ||
|
|
163
|
+
exported.specifiers.length > 0 ||
|
|
164
|
+
exported.declaration?.type !== "VariableDeclaration"
|
|
165
|
+
) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
"workflow scripts may only export a static `const meta = {...}` declaration",
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
const declaration: VariableDeclaration = exported.declaration;
|
|
171
|
+
if (declaration.kind !== "const" || declaration.declarations.length !== 1) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
"workflow metadata must be declared as `export const meta = {...}`",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
const declarator = declaration.declarations[0];
|
|
177
|
+
if (
|
|
178
|
+
declarator.id.type !== "Identifier" ||
|
|
179
|
+
declarator.id.name !== "meta" ||
|
|
180
|
+
!declarator.init
|
|
181
|
+
) {
|
|
182
|
+
throw new Error("workflow scripts may only export a `meta` declaration");
|
|
183
|
+
}
|
|
184
|
+
return { exported, initializer: declarator.init };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parse a workflow as JavaScript, reject module syntax other than the metadata
|
|
189
|
+
* API, statically decode metadata, and remove that declaration from the body.
|
|
190
|
+
* Source inside strings, comments, regexes, and templates is never rewritten.
|
|
191
|
+
*/
|
|
192
|
+
export function prepareWorkflowScript(source: string): PreparedWorkflowScript {
|
|
193
|
+
const program = parseProgram(source);
|
|
194
|
+
let meta: WorkflowMeta = { phases: [] };
|
|
195
|
+
let metadataRange: { start: number; end: number } | undefined;
|
|
196
|
+
|
|
197
|
+
for (const statement of program.body) {
|
|
198
|
+
if (statement.type === "ImportDeclaration") {
|
|
199
|
+
throw new Error("workflow scripts cannot use static imports");
|
|
200
|
+
}
|
|
201
|
+
if (
|
|
202
|
+
statement.type === "ExportDefaultDeclaration" ||
|
|
203
|
+
statement.type === "ExportAllDeclaration"
|
|
204
|
+
) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
"workflow scripts may only export a static `const meta = {...}` declaration",
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
const declaration = metadataDeclaration(statement);
|
|
210
|
+
if (!declaration) continue;
|
|
211
|
+
if (metadataRange)
|
|
212
|
+
throw new Error("workflow metadata may only be declared once");
|
|
213
|
+
meta = sanitizeMeta(literalValue(declaration.initializer));
|
|
214
|
+
metadataRange = {
|
|
215
|
+
start: declaration.exported.start,
|
|
216
|
+
end: declaration.exported.end,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!metadataRange) return { source, meta };
|
|
221
|
+
// Preserve line numbers for parse/runtime errors while removing every byte of
|
|
222
|
+
// executable metadata. A semicolon keeps adjacent statements separated.
|
|
223
|
+
const removed = source.slice(metadataRange.start, metadataRange.end);
|
|
224
|
+
const replacement = `;${removed.slice(1).replace(/[^\n\r]/g, " ")}`;
|
|
225
|
+
return {
|
|
226
|
+
source:
|
|
227
|
+
source.slice(0, metadataRange.start) +
|
|
228
|
+
replacement +
|
|
229
|
+
source.slice(metadataRange.end),
|
|
230
|
+
meta,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const metaCache = new Map<string, WorkflowMeta>();
|
|
235
|
+
const META_CACHE_LIMIT = 32;
|
|
236
|
+
|
|
237
|
+
/** Safe metadata extraction for render paths. Invalid/partial scripts are empty. */
|
|
238
|
+
export function extractMeta(script: string): WorkflowMeta {
|
|
239
|
+
const cached = metaCache.get(script);
|
|
240
|
+
if (cached) return cached;
|
|
241
|
+
let meta: WorkflowMeta = { phases: [] };
|
|
242
|
+
try {
|
|
243
|
+
meta = prepareWorkflowScript(script).meta;
|
|
244
|
+
} catch {
|
|
245
|
+
// Renderers must tolerate partial model output and invalid scripts.
|
|
246
|
+
}
|
|
247
|
+
if (metaCache.size >= META_CACHE_LIMIT) metaCache.clear();
|
|
248
|
+
metaCache.set(script, meta);
|
|
249
|
+
return meta;
|
|
250
|
+
}
|