@intentic/sandbox-contract 1.176.3 → 1.209.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 +65 -0
- package/dist/agent-catalog.d.ts +15 -2
- package/dist/agent-catalog.d.ts.map +1 -1
- package/dist/agent-catalog.js +50 -1
- package/dist/agent-catalog.js.map +1 -1
- package/dist/agent-run-model.d.ts +3 -0
- package/dist/agent-run-model.d.ts.map +1 -0
- package/dist/agent-run-model.js +10 -0
- package/dist/agent-run-model.js.map +1 -0
- package/dist/capability-env.d.ts +2 -0
- package/dist/capability-env.d.ts.map +1 -0
- package/dist/capability-env.js +2 -0
- package/dist/capability-env.js.map +1 -0
- package/dist/chores/chores.d.ts +9 -0
- package/dist/chores/chores.d.ts.map +1 -1
- package/dist/chores/chores.js +257 -18
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/digest.js +0 -0
- package/dist/chores/digest.js.map +1 -1
- package/dist/chores/extension-update.d.ts +9 -0
- package/dist/chores/extension-update.d.ts.map +1 -0
- package/dist/chores/extension-update.js +15 -0
- package/dist/chores/extension-update.js.map +1 -0
- package/dist/chores/fix-deps.d.ts +10 -0
- package/dist/chores/fix-deps.d.ts.map +1 -0
- package/dist/chores/fix-deps.js +18 -0
- package/dist/chores/fix-deps.js.map +1 -0
- package/dist/chores/index.d.ts +7 -2
- package/dist/chores/index.d.ts.map +1 -1
- package/dist/chores/index.js +4 -1
- package/dist/chores/index.js.map +1 -1
- package/dist/chores/probes.d.ts.map +1 -1
- package/dist/chores/probes.js +127 -3
- package/dist/chores/probes.js.map +1 -1
- package/dist/chores/stack.d.ts +27 -0
- package/dist/chores/stack.d.ts.map +1 -0
- package/dist/chores/stack.js +121 -0
- package/dist/chores/stack.js.map +1 -0
- package/dist/chores/verdict.d.ts +2 -1
- package/dist/chores/verdict.d.ts.map +1 -1
- package/dist/chores/verdict.js +53 -6
- package/dist/chores/verdict.js.map +1 -1
- package/dist/contract-lock.d.ts +3 -0
- package/dist/contract-lock.d.ts.map +1 -0
- package/dist/contract-lock.js +35 -0
- package/dist/contract-lock.js.map +1 -0
- package/dist/contracts/activity.contract.d.ts +10 -0
- package/dist/contracts/activity.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.d.ts +230 -20
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.js +2 -1
- package/dist/contracts/agent.contract.js.map +1 -1
- package/dist/contracts/agents.contract.d.ts +847 -1
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +3 -1
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/automations.contract.d.ts +89 -0
- package/dist/contracts/automations.contract.d.ts.map +1 -1
- package/dist/contracts/automations.contract.js +3 -1
- package/dist/contracts/automations.contract.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +108 -32
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/capabilities.contract.js +5 -1
- package/dist/contracts/capabilities.contract.js.map +1 -1
- package/dist/contracts/chores.contract.d.ts +43 -0
- package/dist/contracts/chores.contract.d.ts.map +1 -1
- package/dist/contracts/ci.contract.d.ts +5 -0
- package/dist/contracts/ci.contract.d.ts.map +1 -1
- package/dist/contracts/ci.contract.js +2 -2
- package/dist/contracts/ci.contract.js.map +1 -1
- package/dist/contracts/claude.contract.d.ts +3 -14
- package/dist/contracts/claude.contract.d.ts.map +1 -1
- package/dist/contracts/claude.contract.js +2 -3
- package/dist/contracts/claude.contract.js.map +1 -1
- package/dist/contracts/drafts.contract.d.ts +4 -0
- package/dist/contracts/drafts.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.d.ts +28 -12
- package/dist/contracts/endpoints.contract.d.ts.map +1 -1
- package/dist/contracts/endpoints.contract.js +9 -0
- package/dist/contracts/endpoints.contract.js.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +399 -69
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.js +15 -1
- package/dist/contracts/extensions.contract.js.map +1 -1
- package/dist/contracts/git.contract.d.ts +209 -11
- package/dist/contracts/git.contract.d.ts.map +1 -1
- package/dist/contracts/git.contract.js +12 -2
- package/dist/contracts/git.contract.js.map +1 -1
- package/dist/contracts/grok.contract.d.ts +0 -13
- package/dist/contracts/grok.contract.d.ts.map +1 -1
- package/dist/contracts/grok.contract.js +1 -2
- package/dist/contracts/grok.contract.js.map +1 -1
- package/dist/contracts/host.contract.d.ts +39 -0
- package/dist/contracts/host.contract.d.ts.map +1 -1
- package/dist/contracts/host.contract.js +3 -2
- package/dist/contracts/host.contract.js.map +1 -1
- package/dist/contracts/loops.contract.d.ts +343 -0
- package/dist/contracts/loops.contract.d.ts.map +1 -0
- package/dist/contracts/loops.contract.js +11 -0
- package/dist/contracts/loops.contract.js.map +1 -0
- package/dist/contracts/panels.contract.d.ts +6 -0
- package/dist/contracts/panels.contract.d.ts.map +1 -1
- package/dist/contracts/personas.contract.d.ts +112 -0
- package/dist/contracts/personas.contract.d.ts.map +1 -0
- package/dist/contracts/personas.contract.js +13 -0
- package/dist/contracts/personas.contract.js.map +1 -0
- package/dist/contracts/ports.contract.d.ts +1 -0
- package/dist/contracts/ports.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.d.ts +1 -0
- package/dist/contracts/prepush.contract.d.ts.map +1 -1
- package/dist/contracts/prepush.contract.js.map +1 -1
- package/dist/contracts/{codex.contract.d.ts → providers.contract.d.ts} +11 -3
- package/dist/contracts/providers.contract.d.ts.map +1 -0
- package/dist/contracts/providers.contract.js +6 -0
- package/dist/contracts/providers.contract.js.map +1 -0
- package/dist/contracts/public.contract.d.ts +24 -0
- package/dist/contracts/public.contract.d.ts.map +1 -0
- package/dist/contracts/public.contract.js +8 -0
- package/dist/contracts/public.contract.js.map +1 -0
- package/dist/contracts/push.contract.d.ts +3 -3
- package/dist/contracts/secrets.contract.d.ts +9 -0
- package/dist/contracts/secrets.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.d.ts +16 -1
- package/dist/contracts/sessions.contract.d.ts.map +1 -1
- package/dist/contracts/sessions.contract.js +1 -1
- package/dist/contracts/sessions.contract.js.map +1 -1
- package/dist/contracts/settings.contract.d.ts +352 -58
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/settings.contract.js +2 -1
- package/dist/contracts/settings.contract.js.map +1 -1
- package/dist/contracts/share.contract.d.ts +55 -0
- package/dist/contracts/share.contract.d.ts.map +1 -0
- package/dist/contracts/share.contract.js +9 -0
- package/dist/contracts/share.contract.js.map +1 -0
- package/dist/contracts/skills.contract.d.ts +41 -0
- package/dist/contracts/skills.contract.d.ts.map +1 -0
- package/dist/contracts/skills.contract.js +9 -0
- package/dist/contracts/skills.contract.js.map +1 -0
- package/dist/contracts/system.contract.d.ts +204 -8
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.js +10 -2
- package/dist/contracts/system.contract.js.map +1 -1
- package/dist/contracts/workflows.contract.d.ts +575 -0
- package/dist/contracts/workflows.contract.d.ts.map +1 -0
- package/dist/contracts/workflows.contract.js +13 -0
- package/dist/contracts/workflows.contract.js.map +1 -0
- package/dist/contracts/workspace.contract.d.ts +18 -4
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +6 -3
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/conversation-ids.d.ts +2 -0
- package/dist/conversation-ids.d.ts.map +1 -0
- package/dist/conversation-ids.js +113 -0
- package/dist/conversation-ids.js.map +1 -0
- package/dist/events.d.ts +523 -17
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +106 -13
- package/dist/events.js.map +1 -1
- package/dist/history-state.d.ts.map +1 -1
- package/dist/history-state.js +10 -0
- package/dist/history-state.js.map +1 -1
- package/dist/hostnames.d.ts +4 -0
- package/dist/hostnames.d.ts.map +1 -1
- package/dist/hostnames.js +7 -2
- package/dist/hostnames.js.map +1 -1
- package/dist/index.d.ts +3791 -478
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +39 -17
- package/dist/index.js.map +1 -1
- package/dist/listener-protocol.d.ts +77 -0
- package/dist/listener-protocol.d.ts.map +1 -0
- package/dist/listener-protocol.js +33 -0
- package/dist/listener-protocol.js.map +1 -0
- package/dist/model-order.d.ts +1 -0
- package/dist/model-order.d.ts.map +1 -1
- package/dist/model-order.js +16 -0
- package/dist/model-order.js.map +1 -1
- package/dist/output-fields.d.ts +30 -0
- package/dist/output-fields.d.ts.map +1 -0
- package/dist/output-fields.js +57 -0
- package/dist/output-fields.js.map +1 -0
- package/dist/path-refs.d.ts.map +1 -1
- package/dist/path-refs.js.map +1 -1
- package/dist/publish-drafts.d.ts +7 -0
- package/dist/publish-drafts.d.ts.map +1 -0
- package/dist/publish-drafts.js +20 -0
- package/dist/publish-drafts.js.map +1 -0
- package/dist/quick-model.d.ts +2 -1
- package/dist/quick-model.d.ts.map +1 -1
- package/dist/quick-model.js +18 -14
- package/dist/quick-model.js.map +1 -1
- package/dist/routes.d.ts +3 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +63 -0
- package/dist/routes.js.map +1 -1
- package/dist/runtime-state.d.ts +33 -0
- package/dist/runtime-state.d.ts.map +1 -0
- package/dist/runtime-state.js +14 -0
- package/dist/runtime-state.js.map +1 -0
- package/dist/schemas.d.ts +4886 -851
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +993 -192
- package/dist/schemas.js.map +1 -1
- package/dist/search-globs.d.ts +6 -0
- package/dist/search-globs.d.ts.map +1 -0
- package/dist/search-globs.js +39 -0
- package/dist/search-globs.js.map +1 -0
- package/dist/share-paths.d.ts +8 -0
- package/dist/share-paths.d.ts.map +1 -0
- package/dist/share-paths.js +21 -0
- package/dist/share-paths.js.map +1 -0
- package/dist/title.d.ts.map +1 -1
- package/dist/title.js +1 -1
- package/dist/title.js.map +1 -1
- package/dist/tunnel-ids.d.ts +1 -0
- package/dist/tunnel-ids.d.ts.map +1 -1
- package/dist/tunnel-ids.js +1 -0
- package/dist/tunnel-ids.js.map +1 -1
- package/dist/versions.d.ts +4 -0
- package/dist/versions.d.ts.map +1 -0
- package/dist/versions.js +15 -0
- package/dist/versions.js.map +1 -0
- package/dist/workflow-faults.d.ts +4 -0
- package/dist/workflow-faults.d.ts.map +1 -0
- package/dist/workflow-faults.js +115 -0
- package/dist/workflow-faults.js.map +1 -0
- package/dist/workspace-state.d.ts +280 -1
- package/dist/workspace-state.d.ts.map +1 -1
- package/dist/workspace-state.js +163 -21
- package/dist/workspace-state.js.map +1 -1
- package/package.json +30 -6
- package/src/agent-catalog.test.ts +147 -7
- package/src/agent-catalog.ts +233 -21
- package/src/agent-run-model.test.ts +60 -0
- package/src/agent-run-model.ts +51 -0
- package/src/capability-env.ts +10 -0
- package/src/capability-ledger.test.ts +110 -0
- package/src/chores/chores.test.ts +32 -0
- package/src/chores/chores.ts +456 -32
- package/src/chores/digest.ts +0 -0
- package/src/chores/extension-update.ts +37 -0
- package/src/chores/fix-deps.ts +40 -0
- package/src/chores/index.ts +7 -2
- package/src/chores/probes.test.ts +155 -3
- package/src/chores/probes.ts +200 -4
- package/src/chores/stack.test.ts +126 -0
- package/src/chores/stack.ts +244 -0
- package/src/chores/verdict.test.ts +357 -19
- package/src/chores/verdict.ts +95 -21
- package/src/contract-lock.test.ts +17 -0
- package/src/contract-lock.ts +66 -0
- package/src/contracts/agent.contract.ts +6 -0
- package/src/contracts/agents.contract.ts +12 -0
- package/src/contracts/automations.contract.ts +17 -1
- package/src/contracts/capabilities.contract.ts +22 -0
- package/src/contracts/ci.contract.ts +10 -2
- package/src/contracts/claude.contract.ts +8 -4
- package/src/contracts/endpoints.contract.ts +21 -0
- package/src/contracts/extensions.contract.ts +42 -0
- package/src/contracts/git.contract.ts +33 -6
- package/src/contracts/grok.contract.ts +4 -3
- package/src/contracts/host.contract.ts +13 -2
- package/src/contracts/loops.contract.ts +62 -0
- package/src/contracts/personas.contract.ts +56 -0
- package/src/contracts/prepush.contract.ts +4 -3
- package/src/contracts/providers.contract.ts +23 -0
- package/src/contracts/public.contract.ts +14 -0
- package/src/contracts/sessions.contract.ts +5 -1
- package/src/contracts/settings.contract.ts +5 -0
- package/src/contracts/share.contract.ts +20 -0
- package/src/contracts/skills.contract.ts +24 -0
- package/src/contracts/system.contract.ts +27 -3
- package/src/contracts/workflows.contract.ts +78 -0
- package/src/contracts/workspace.contract.ts +10 -2
- package/src/conversation-ids.test.ts +25 -0
- package/src/conversation-ids.ts +160 -0
- package/src/events.test.ts +20 -1
- package/src/events.ts +404 -43
- package/src/history-state.ts +30 -1
- package/src/hostnames.ts +20 -6
- package/src/index.ts +58 -17
- package/src/listener-protocol.ts +96 -0
- package/src/model-order.test.ts +32 -1
- package/src/model-order.ts +56 -3
- package/src/output-fields.ts +111 -0
- package/src/path-refs.test.ts +4 -4
- package/src/path-refs.ts +5 -5
- package/src/publish-drafts.ts +59 -0
- package/src/quick-model.test.ts +70 -27
- package/src/quick-model.ts +76 -40
- package/src/routes.test.ts +91 -3
- package/src/routes.ts +135 -0
- package/src/runtime-state.test.ts +45 -0
- package/src/runtime-state.ts +124 -0
- package/src/schemas.test.ts +16 -29
- package/src/schemas.ts +3475 -552
- package/src/search-globs.test.ts +46 -0
- package/src/search-globs.ts +76 -0
- package/src/share-paths.test.ts +48 -0
- package/src/share-paths.ts +68 -0
- package/src/state-portability.ts +8 -8
- package/src/title.test.ts +20 -1
- package/src/title.ts +12 -3
- package/src/tunnel-ids.ts +15 -1
- package/src/versions.test.ts +39 -0
- package/src/versions.ts +48 -0
- package/src/workflow-faults.test.ts +127 -0
- package/src/workflow-faults.ts +188 -0
- package/src/workspace-state.test.ts +169 -15
- package/src/workspace-state.ts +427 -35
- package/dist/contracts/codex.contract.d.ts.map +0 -1
- package/dist/contracts/codex.contract.js +0 -6
- package/dist/contracts/codex.contract.js.map +0 -1
- package/dist/contracts/gemini.contract.d.ts +0 -16
- package/dist/contracts/gemini.contract.d.ts.map +0 -1
- package/dist/contracts/gemini.contract.js +0 -6
- package/dist/contracts/gemini.contract.js.map +0 -1
- package/dist/contracts/kimi.contract.d.ts +0 -16
- package/dist/contracts/kimi.contract.d.ts.map +0 -1
- package/dist/contracts/kimi.contract.js +0 -6
- package/dist/contracts/kimi.contract.js.map +0 -1
- package/dist/contracts/komodo.contract.d.ts +0 -125
- package/dist/contracts/komodo.contract.d.ts.map +0 -1
- package/dist/contracts/komodo.contract.js +0 -14
- package/dist/contracts/komodo.contract.js.map +0 -1
- package/dist/contracts/memory.contract.d.ts +0 -34
- package/dist/contracts/memory.contract.d.ts.map +0 -1
- package/dist/contracts/memory.contract.js +0 -9
- package/dist/contracts/memory.contract.js.map +0 -1
- package/src/contracts/codex.contract.ts +0 -10
- package/src/contracts/gemini.contract.ts +0 -11
- package/src/contracts/kimi.contract.ts +0 -9
- package/src/contracts/komodo.contract.ts +0 -36
- package/src/contracts/memory.contract.ts +0 -13
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { includeGlobs } from "./search-globs.js";
|
|
3
|
+
|
|
4
|
+
test("an empty field scopes nothing", () => {
|
|
5
|
+
expect(includeGlobs(undefined)).toEqual({ globs: [], notGlobs: [] });
|
|
6
|
+
expect(includeGlobs(" , ")).toEqual({ globs: [], notGlobs: [] });
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// The reported bug: a file name read as a folder name found nothing. Both forms come out, as VSCode's
|
|
10
|
+
// expandGlobalGlob emits them.
|
|
11
|
+
test("a bare name is a file as well as a folder, at any depth", () => {
|
|
12
|
+
expect(includeGlobs(`package.json`).globs).toEqual([`**/package.json`, `**/package.json/**`]);
|
|
13
|
+
expect(includeGlobs(`docs`).globs).toEqual([`**/docs`, `**/docs/**`]);
|
|
14
|
+
// A trailing slash is noise — the folder form is generated either way.
|
|
15
|
+
expect(includeGlobs(`docs/`).globs).toEqual([`**/docs`, `**/docs/**`]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("a path is matched at any depth unless ./ anchors it to the root", () => {
|
|
19
|
+
expect(includeGlobs(`src/db`).globs).toEqual([`**/src/db`, `**/src/db/**`]);
|
|
20
|
+
expect(includeGlobs(`./src/db`).globs).toEqual([`./src/db`, `./src/db/**`]);
|
|
21
|
+
expect(includeGlobs(`/src/db`).globs).toEqual([`./src/db`, `./src/db/**`]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("a leading dot is the extension shorthand", () => {
|
|
25
|
+
expect(includeGlobs(`.ts`).globs).toEqual([`**/*.ts`, `**/*.ts/**`]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("wildcards are passed through as typed", () => {
|
|
29
|
+
expect(includeGlobs(`*.test.ts`).globs).toEqual([`**/*.test.ts`, `**/*.test.ts/**`]);
|
|
30
|
+
expect(includeGlobs(`src/**/*.vue`).globs).toEqual([`**/src/**/*.vue`, `**/src/**/*.vue/**`]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("commas separate patterns except inside a brace group or a character class", () => {
|
|
34
|
+
expect(includeGlobs(`*.{ts,py}`).globs).toEqual([`**/*.{ts,py}`, `**/*.{ts,py}/**`]);
|
|
35
|
+
expect(includeGlobs(`f[a,b].ts`).globs).toEqual([`**/f[a,b].ts`, `**/f[a,b].ts/**`]);
|
|
36
|
+
expect(includeGlobs(`docs, *.md`).globs).toEqual([`**/docs`, `**/docs/**`, `**/*.md`, `**/*.md/**`]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("a leading ! excludes instead", () => {
|
|
40
|
+
expect(includeGlobs(`src, !*.test.ts`)).toEqual({
|
|
41
|
+
globs: [`**/src`, `**/src/**`],
|
|
42
|
+
notGlobs: [`**/*.test.ts`, `**/*.test.ts/**`],
|
|
43
|
+
});
|
|
44
|
+
// A lone "!" excludes nothing — expanded, it would have matched everything.
|
|
45
|
+
expect(includeGlobs(`!`)).toEqual({ globs: [], notGlobs: [] });
|
|
46
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/* What the search box's second field MEANS — VSCode's "files to include" grammar, read the way VSCode reads it
|
|
2
|
+
* (its queryBuilder's parseSearchPaths + expandGlobalGlob), and answered as the two path-glob lists the search
|
|
3
|
+
* engine takes.
|
|
4
|
+
*
|
|
5
|
+
* The rules that make it feel like the editor's field, each one load-bearing:
|
|
6
|
+
*
|
|
7
|
+
* `package.json` a bare name is a FILE as well as a folder — every segment expands to BOTH `**\/p` and
|
|
8
|
+
* `**\/p/**`. Reading it as a folder alone is what made a file name find nothing.
|
|
9
|
+
* `src/db` a path is still matched at any depth, not anchored — `**\/src/db`. VSCode only anchors
|
|
10
|
+
* `./src/db` when the segment starts with `./` (or `/`), which is how you say "the one at the root".
|
|
11
|
+
* `.ts` a leading dot is shorthand for the extension: it becomes `*.ts`.
|
|
12
|
+
* `docs/` a trailing slash is noise; the folder form is generated either way.
|
|
13
|
+
* `*.{ts,vue}` commas separate patterns EXCEPT inside `{…}` or `[…]`, which are one pattern's own syntax.
|
|
14
|
+
* `!**\/*.spec.ts` a leading `!` excludes. This is ours, not VSCode's — the editor spends a second box on
|
|
15
|
+
* exclusions and the explorer's sidebar has room for one field.
|
|
16
|
+
*
|
|
17
|
+
* It lives in the contract package because both ends run it: the daemon turns it into engine scope, and the
|
|
18
|
+
* recorded demo answers /workspace/search itself. Two readings of one field would make the same text mean
|
|
19
|
+
* different things depending on which one answered. */
|
|
20
|
+
|
|
21
|
+
// VSCode's splitGlobAware: the split character is inert inside a brace group or a character class, so
|
|
22
|
+
// `*.{ts,py}` and `f[a,b].ts` survive as single patterns.
|
|
23
|
+
const splitPatterns = (include: string, splitChar: string): string[] => {
|
|
24
|
+
const segments: string[] = [];
|
|
25
|
+
let current = ``;
|
|
26
|
+
let inBraces = false;
|
|
27
|
+
let inBrackets = false;
|
|
28
|
+
for (const char of include) {
|
|
29
|
+
if (char === splitChar && !inBraces && !inBrackets) {
|
|
30
|
+
segments.push(current);
|
|
31
|
+
current = ``;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
inBraces = char === `{` ? true : char === `}` ? false : inBraces;
|
|
35
|
+
inBrackets = char === `[` ? true : char === `]` ? false : inBrackets;
|
|
36
|
+
current += char;
|
|
37
|
+
}
|
|
38
|
+
segments.push(current);
|
|
39
|
+
return segments.map((segment) => segment.trim()).filter((segment) => segment !== ``);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/* One typed segment → the globs that answer it. The pair is the whole trick: `p` matches the file, `p/**`
|
|
43
|
+
* matches everything under a folder of that name, and either may be what the reader meant.
|
|
44
|
+
*
|
|
45
|
+
* `./p` and `/p` anchor at the workspace root; the `./` is kept on the way out because that is exactly how the
|
|
46
|
+
* engine's glob distinguishes an anchored pattern from a name it should look for at any depth. */
|
|
47
|
+
const expand = (segment: string): string[] => {
|
|
48
|
+
const trimmed = segment.replace(/\/+$/, ``);
|
|
49
|
+
if (trimmed === ``) {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
const anchored = /^\.?\//.test(trimmed);
|
|
53
|
+
if (anchored) {
|
|
54
|
+
const path = trimmed.replace(/^\.?\//, ``).replace(/^\/+/, ``);
|
|
55
|
+
return path === `` ? [] : [`./${path}`, `./${path}/**`];
|
|
56
|
+
}
|
|
57
|
+
// ".ts" is how people write an extension filter; VSCode reads it as "*.ts" rather than as a hidden file.
|
|
58
|
+
const pattern = trimmed.startsWith(`.`) ? `*${trimmed}` : trimmed;
|
|
59
|
+
return [`**/${pattern}`, `**/${pattern}/**`];
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export interface IncludeGlobs {
|
|
63
|
+
// Files the search is limited to — empty means the whole workspace. OR'ed: any pattern matching admits.
|
|
64
|
+
readonly globs: readonly string[];
|
|
65
|
+
// Files kept out of it, whatever the includes said.
|
|
66
|
+
readonly notGlobs: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export const includeGlobs = (include: string | undefined): IncludeGlobs => {
|
|
70
|
+
const segments = splitPatterns(include ?? ``, `,`);
|
|
71
|
+
return {
|
|
72
|
+
globs: segments.filter((segment) => !segment.startsWith(`!`)).flatMap(expand),
|
|
73
|
+
// A bare "!" excludes nothing; without this it would expand into a glob that matches everything.
|
|
74
|
+
notGlobs: segments.filter((segment) => segment.startsWith(`!`)).flatMap((segment) => expand(segment.slice(1))),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { SHARE_ID, shareId, shareStem } from "./share-paths.js";
|
|
3
|
+
|
|
4
|
+
/* The id a shared conversation is filed under is two things at once: the readable half of a link somebody
|
|
5
|
+
* pastes, and the half of the address that has to be safe to join onto a directory path. Both halves are
|
|
6
|
+
* checked here, because the id is minted from a TITLE the user typed. */
|
|
7
|
+
|
|
8
|
+
describe("the readable half", () => {
|
|
9
|
+
it("makes a link that says what it points at", () => {
|
|
10
|
+
expect(shareId("Fix the login redirect loop", "3f9c")).toBe("fix-the-login-redirect-loop-3f9c");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("keeps a long title from running away with the address", () => {
|
|
14
|
+
const id = shareId("a".repeat(200), "3f9c");
|
|
15
|
+
expect(id.length).toBeLessThanOrEqual(64);
|
|
16
|
+
expect(SHARE_ID.test(id)).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// A title in a writing system this alphabet cannot carry leaves the random half standing alone, which is
|
|
20
|
+
// still a perfectly good id — and a better outcome than an address full of percent-encoding.
|
|
21
|
+
it("falls back to the random half when a title has no letters to give", () => {
|
|
22
|
+
expect(shareStem("日本語のタイトル")).toBe("");
|
|
23
|
+
expect(shareId("日本語のタイトル", "3f9c")).toBe("3f9c");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("the safe half", () => {
|
|
28
|
+
/* The id is joined onto a path, so a title is the one attacker-shaped input in it. These are the shapes
|
|
29
|
+
* that would matter if the alphabet were not closed. */
|
|
30
|
+
it.each([
|
|
31
|
+
["../../etc/passwd", "etc-passwd"],
|
|
32
|
+
["a/b", "a-b"],
|
|
33
|
+
[".hidden", "hidden"],
|
|
34
|
+
[" spaces everywhere ", "spaces-everywhere"],
|
|
35
|
+
["Emoji 🎉 title", "emoji-title"],
|
|
36
|
+
["UPPER Case", "upper-case"],
|
|
37
|
+
])("reduces %j to a name that can only be a name", (title, stem) => {
|
|
38
|
+
expect(shareStem(title)).toBe(stem);
|
|
39
|
+
expect(SHARE_ID.test(shareId(title, "3f9c"))).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The guard the daemon applies before joining an id onto a directory, held to the whole minted space: no
|
|
43
|
+
// title can produce something it rejects, which is what makes a rejection there a bug rather than input.
|
|
44
|
+
it("mints nothing its own guard would refuse", () => {
|
|
45
|
+
const titles = ["Fix login", "../..", "🎉", "a".repeat(300), "-leading-dash", "trailing-dash-"];
|
|
46
|
+
expect(titles.every((title) => SHARE_ID.test(shareId(title, "00112233445566ff")))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/* WHERE A SHARED CONVERSATION LIVES INSIDE THE OUTBOX — one layout, stated once, because three separate
|
|
2
|
+
* things have to agree on it and only one of them is code that runs at the same time as the others.
|
|
3
|
+
*
|
|
4
|
+
* public/
|
|
5
|
+
* conversations/
|
|
6
|
+
* _viewer/ the page's built assets, copied in once and shared by every share
|
|
7
|
+
* <id>/
|
|
8
|
+
* index.html the page, with its conversation baked in
|
|
9
|
+
* files/ the pictures that conversation showed, copied out of the workspace
|
|
10
|
+
*
|
|
11
|
+
* The VIEWER's build has to know its own address before it is ever published (its asset URLs are absolute —
|
|
12
|
+
* `/conversations/_viewer/assets/…` — so that one copy of the assets serves every share and a recipient's
|
|
13
|
+
* browser caches them across links). The DAEMON has to write the tree. And the outbox's own rules have to
|
|
14
|
+
* leave all of it alone: nothing here begins with a dot, which is the one shape that is never served.
|
|
15
|
+
*
|
|
16
|
+
* A directory per share rather than a bare `<id>.html`, so a share owns its pictures — "stop sharing" is then
|
|
17
|
+
* one directory removed, and cannot half-succeed by leaving a folder of someone's screenshots behind. */
|
|
18
|
+
|
|
19
|
+
// Top-level folder inside `public/`. Reads as what it is in the address bar, which matters: this is a string
|
|
20
|
+
// people paste to each other.
|
|
21
|
+
export const SHARE_DIR = "conversations";
|
|
22
|
+
|
|
23
|
+
// The one copy of the page's assets. Underscored to sort away from the shares themselves and to read as
|
|
24
|
+
// machinery rather than as somebody's conversation.
|
|
25
|
+
export const SHARE_VIEWER_DIR = "_viewer";
|
|
26
|
+
|
|
27
|
+
// Where a share's pictures sit, relative to its own page — and therefore also the prefix every rewritten
|
|
28
|
+
// image path in the payload carries.
|
|
29
|
+
export const SHARE_FILES_DIR = "files";
|
|
30
|
+
|
|
31
|
+
// The absolute path the built assets are served from, and the `base` the page is built with.
|
|
32
|
+
export const SHARE_VIEWER_BASE = `/${SHARE_DIR}/${SHARE_VIEWER_DIR}/`;
|
|
33
|
+
|
|
34
|
+
/* The id a share is filed under: a readable stem from the title, plus randomness that is the ONLY thing
|
|
35
|
+
* standing between a stranger and the conversation.
|
|
36
|
+
*
|
|
37
|
+
* Both halves earn their place. The stem is what makes a pasted link say what it points at ("…/conversations/
|
|
38
|
+
* login-redirect-fix-3f9c…"), which is most of why anyone trusts clicking one. The tail is the security: the
|
|
39
|
+
* outbox answers on an unguessable hostname AND requires the exact path (public-files.ts rule 4 — there is no
|
|
40
|
+
* listing), so an address is protected by the sum of the two, and the half this module controls must not be
|
|
41
|
+
* derivable from a title anyone could guess. */
|
|
42
|
+
const STEM_MAX = 48;
|
|
43
|
+
|
|
44
|
+
export const shareStem = (title: string): string => {
|
|
45
|
+
const stem = title
|
|
46
|
+
.toLowerCase()
|
|
47
|
+
.normalize("NFKD")
|
|
48
|
+
// Anything that is not a plain letter or digit becomes a separator, which retires accents, punctuation,
|
|
49
|
+
// emoji and every writing system that would otherwise arrive percent-encoded in the address bar.
|
|
50
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
51
|
+
.replace(/^-+|-+$/g, "")
|
|
52
|
+
.slice(0, STEM_MAX)
|
|
53
|
+
.replace(/-+$/, "");
|
|
54
|
+
// A title made entirely of characters the rule above drops (a CJK title, an emoji) leaves nothing to read;
|
|
55
|
+
// the id is then the random half alone, which is still a perfectly good id.
|
|
56
|
+
return stem;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// `<stem>-<random>`, or the random half alone when the title had no letters to give.
|
|
60
|
+
export const shareId = (title: string, random: string): string => {
|
|
61
|
+
const stem = shareStem(title);
|
|
62
|
+
return stem === "" ? random : `${stem}-${random}`;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// What a share's own id must look like before it is ever joined onto a path — the same shape the daemon's
|
|
66
|
+
// other id guards take (transcript-record.ts FILE_ID), and the reason a share id from the wire can be trusted
|
|
67
|
+
// into a directory name.
|
|
68
|
+
export const SHARE_ID = /^[a-z0-9][a-z0-9-]{0,95}$/;
|
package/src/state-portability.ts
CHANGED
|
@@ -40,15 +40,15 @@ export interface StateFile {
|
|
|
40
40
|
|
|
41
41
|
/* The entry that owns a path, LONGEST PREFIX FIRST.
|
|
42
42
|
*
|
|
43
|
-
* Manifests deliberately nest
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* first
|
|
47
|
-
* the same reason a .gitignore consults its deepest matcher first.
|
|
43
|
+
* Manifests may deliberately nest when one subtree has a different lifecycle. First-match resolution would
|
|
44
|
+
* classify the child by whichever entry happened to be listed first, so the specific entry has to win over the
|
|
45
|
+
* general one by construction rather than by list order — the same reason a .gitignore consults its deepest
|
|
46
|
+
* matcher first.
|
|
48
47
|
*
|
|
49
|
-
* Undefined for a path no entry claims. Callers decide what that means:
|
|
50
|
-
*
|
|
51
|
-
*
|
|
48
|
+
* Undefined for a path no entry claims. Callers decide what that means: workspace content (including an
|
|
49
|
+
* extension's undeclared output) defaults to `carry`, while unclaimed history machinery defaults to `derived`.
|
|
50
|
+
* The daemon's state-path coverage guard is what prevents one of its own credential stores from relying on the
|
|
51
|
+
* workspace default.
|
|
52
52
|
*/
|
|
53
53
|
export const stateFileFor = <T extends StateFile>(path: string, files: readonly T[]): T | undefined =>
|
|
54
54
|
files
|
package/src/title.test.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { expect, test } from "vitest";
|
|
2
|
+
import { AgentTurnSchema } from "./schemas.js";
|
|
2
3
|
import { deriveTitle } from "./title.js";
|
|
3
4
|
|
|
4
5
|
/* The name a conversation opens under. Every case here is a shape the naive rule (collapse whitespace, cut at
|
|
@@ -58,6 +59,24 @@ test("cuts on a word boundary instead of mid-syllable", () => {
|
|
|
58
59
|
expect(prompt[kept.length]).toBe(" ");
|
|
59
60
|
});
|
|
60
61
|
|
|
62
|
+
test("keeps a cut it cannot put on a word boundary inside the budget anyway", () => {
|
|
63
|
+
/* The sibling of the case above: a long unbroken token straddling the cut leaves no space late enough to
|
|
64
|
+
* back off to, so the length clamp alone ends the title — and it used to end it one character OVER. That
|
|
65
|
+
* character is not a cosmetic overflow, it is a 400 on the turn carrying the name, and it wedged the
|
|
66
|
+
* conversation for good: the browser stores the derived title before it sends, so every retry re-sent the
|
|
67
|
+
* same rejected one. */
|
|
68
|
+
const prompt = `In one of the sandboxes I have experienced "CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS" limit of 20. Make it configurable in sandbox settings somewhere at /sandbox/agent`;
|
|
69
|
+
const title = deriveTitle(prompt);
|
|
70
|
+
|
|
71
|
+
expect(title.endsWith("…")).toBe(true);
|
|
72
|
+
// Asserted THROUGH the contract rather than against a repeated literal: the clamp and the cap drifting
|
|
73
|
+
// apart is the entire bug, and a second copy of the number is how they drift.
|
|
74
|
+
expect(AgentTurnSchema.safeParse({ prompt, title }).success).toBe(true);
|
|
75
|
+
// Not a property of that one sentence — any token wide enough to swallow the window does it.
|
|
76
|
+
const wide = `Investigate ${"X".repeat(120)} please`;
|
|
77
|
+
expect(AgentTurnSchema.safeParse({ prompt: wide, title: deriveTitle(wide) }).success).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
|
|
61
80
|
test("keeps a link's last meaningful segment rather than its host and scaffolding", () => {
|
|
62
81
|
const title = deriveTitle("Look at https://gitlab.com/radarsu/intentic/-/merge_requests/42 and tell me what broke");
|
|
63
82
|
|
|
@@ -67,7 +86,7 @@ test("keeps a link's last meaningful segment rather than its host and scaffoldin
|
|
|
67
86
|
});
|
|
68
87
|
|
|
69
88
|
test("collapses a deep path to its basename and leaves a shallow one alone", () => {
|
|
70
|
-
expect(deriveTitle("Refactor
|
|
89
|
+
expect(deriveTitle("Refactor _editor/web/src/composables/chat/conversation.ts")).toBe("Refactor conversation.ts");
|
|
71
90
|
// Two segments already read as a place; collapsing them would lose the only context the reference carries.
|
|
72
91
|
expect(deriveTitle("Refactor src/foo.ts")).toBe("Refactor src/foo.ts");
|
|
73
92
|
});
|
package/src/title.ts
CHANGED
|
@@ -142,14 +142,14 @@ const shortUrl = (token: string): string => {
|
|
|
142
142
|
};
|
|
143
143
|
|
|
144
144
|
// A path is worth its basename once it is deep enough that the lead is scaffolding — `src/foo.ts` already
|
|
145
|
-
// reads as a place, `
|
|
145
|
+
// reads as a place, `_editor/web/src/composables/chat/conversation.ts` reads as a wall.
|
|
146
146
|
const shortPath = (token: string): string => {
|
|
147
147
|
const segments = token.replace(/^@/, "").split("/");
|
|
148
148
|
return segments.length > 2 ? (segments.findLast((segment) => segment !== "") ?? token) : token;
|
|
149
149
|
};
|
|
150
150
|
|
|
151
151
|
// Trailing punctuation belongs to the sentence, not to the reference inside it, so it is set aside across the
|
|
152
|
-
// shortening and put back — `see
|
|
152
|
+
// shortening and put back — `see _editor/web/src/foo.ts,` collapses to `see foo.ts,`.
|
|
153
153
|
const shortReference = (token: string): string => {
|
|
154
154
|
const match = /^(.*?)([\s,.;:!?)\]]*)$/s.exec(token);
|
|
155
155
|
const core = match?.[1] ?? token;
|
|
@@ -193,11 +193,20 @@ const sentencesOf = (text: string): string[] => {
|
|
|
193
193
|
return parts;
|
|
194
194
|
};
|
|
195
195
|
|
|
196
|
+
/* THE ELLIPSIS IS PART OF THE BUDGET, not an addition to it. MAX_LENGTH is a STORAGE cap the wire contract
|
|
197
|
+
* enforces (AgentTurnSchema.title), so a title one character over it is not a wide tab — it is a 400 on the
|
|
198
|
+
* turn that carried it. Cutting to MAX_LENGTH and appending afterwards was exactly that off-by-one, and it
|
|
199
|
+
* hid for as long as it did because the word-boundary backoff almost always absorbed it: an ordinary sentence
|
|
200
|
+
* has a space late in the window, so `kept` came back short and the ellipsis fit. What has no such space is a
|
|
201
|
+
* long unbroken token straddling the cut — a SCREAMING_SNAKE env var, a hash, a base64 blob — and there the
|
|
202
|
+
* fallback kept the full-width slice and made it one character too long. The conversation then wedged: the
|
|
203
|
+
* browser writes the derived title into its state before it sends, so every later turn re-sent the same
|
|
204
|
+
* rejected name. */
|
|
196
205
|
const clamped = (text: string): string => {
|
|
197
206
|
if (text.length <= MAX_LENGTH) {
|
|
198
207
|
return text;
|
|
199
208
|
}
|
|
200
|
-
const cut = text.slice(0, MAX_LENGTH);
|
|
209
|
+
const cut = text.slice(0, MAX_LENGTH - 1);
|
|
201
210
|
const boundary = cut.lastIndexOf(" ");
|
|
202
211
|
const kept = boundary >= MIN_WORD_CUT ? cut.slice(0, boundary) : cut;
|
|
203
212
|
return `${kept.replace(/[\s,;:—–-]+$/, "")}…`;
|
package/src/tunnel-ids.ts
CHANGED
|
@@ -7,7 +7,7 @@ export const sha256Hex = (value: string): string => createHash("sha256").update(
|
|
|
7
7
|
|
|
8
8
|
// The sandbox's stable 12-hex id, digested from the connect token. Used by:
|
|
9
9
|
// • the CLI's sandbox-tunnel bootstrap (sandbox-tunnel.ts) to name the tunnel + DNS
|
|
10
|
-
// • the
|
|
10
|
+
// • the preview hostname builder beside it (hostnames.ts)
|
|
11
11
|
// • the sandbox daemon's sync SSH hostname derivation (sync.ts)
|
|
12
12
|
// All three MUST agree on the digest, so it lives in the contract they share.
|
|
13
13
|
export const sandboxIdFromToken = (connectToken: string): string | undefined =>
|
|
@@ -41,3 +41,17 @@ export const PORT_SLOT_COUNT = 8;
|
|
|
41
41
|
* the digest it shares with sandboxIdFromToken. */
|
|
42
42
|
export const portSlotsFromToken = (connectToken: string): readonly string[] =>
|
|
43
43
|
Array.from({ length: PORT_SLOT_COUNT }, (_, index) => sha256Hex(`${connectToken}:port:${index}`).slice(0, 12));
|
|
44
|
+
|
|
45
|
+
/* THE OUTBOX SLOT — the `public-<slot>` half of `public-<slot>-<sandboxId>.<zone>`, where the daemon serves the
|
|
46
|
+
* workspace's `public/` directory (PUBLIC_DIR in @intentic/workspace-ignore).
|
|
47
|
+
*
|
|
48
|
+
* Salted for the reason above, and it matters more here than it does for ports: a forwarded port is a live
|
|
49
|
+
* server the owner started minutes ago, whereas a published file sits there. One record per sandbox, stable
|
|
50
|
+
* across restarts so a link stays good for as long as the file does, and derivable only by parties holding the
|
|
51
|
+
* token — the daemon that serves and the platform that mints. Not the browser: it reads the URL off the /public
|
|
52
|
+
* response, exactly as it reads previewUrl off /ports.
|
|
53
|
+
*
|
|
54
|
+
* The unguessable hostname is also what carries the security story, because the files under it have no auth in
|
|
55
|
+
* front of them: with no directory listing (public-files.ts refuses one) an outsider must guess a 12-hex label
|
|
56
|
+
* AND a filename to reach anything at all. */
|
|
57
|
+
export const publicSlotFromToken = (connectToken: string): string => sha256Hex(`${connectToken}:public`).slice(0, 12);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { DEV_VERSION, isBehind, isNewer } from "./versions.js";
|
|
3
|
+
|
|
4
|
+
test("isNewer compares dotted numeric versions", () => {
|
|
5
|
+
expect(isNewer("1.3.0", "1.2.9")).toBe(true); // newer minor
|
|
6
|
+
expect(isNewer("1.2.10", "1.2.9")).toBe(true); // numeric, not lexical
|
|
7
|
+
expect(isNewer("2.0.0", "1.9.9")).toBe(true); // newer major
|
|
8
|
+
expect(isNewer("1.2.3", "1.2.3")).toBe(false); // equal
|
|
9
|
+
expect(isNewer("1.2.2", "1.2.3")).toBe(false); // older
|
|
10
|
+
expect(isNewer("1.2", "1.2.0")).toBe(false); // missing segment treated as 0
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("isBehind reports a released build that a newer release has passed", () => {
|
|
14
|
+
expect(isBehind("1.182.0", "1.183.0")).toBe(true);
|
|
15
|
+
expect(isBehind("1.183.0", "1.183.0")).toBe(false);
|
|
16
|
+
expect(isBehind("1.184.0", "1.183.0")).toBe(false); // ahead of what this sandbox knows about
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Every way of not knowing resolves to silence. Each of these once had a plausible argument for nagging, and
|
|
20
|
+
// each would have nagged somebody who could do nothing about it.
|
|
21
|
+
test("isBehind stays quiet whenever it cannot be sure", () => {
|
|
22
|
+
expect(isBehind(undefined, "1.183.0")).toBe(false); // the agent reports no version
|
|
23
|
+
expect(isBehind("1.182.0", undefined)).toBe(false); // this sandbox has no latest to compare against
|
|
24
|
+
expect(isBehind(DEV_VERSION, "1.183.0")).toBe(false); // a build made from a working tree, not a release
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/* A version with a segment that will not parse is read by its numeric prefix, and the failure is one-directional:
|
|
28
|
+
* it can withhold a nag, never invent one. Both halves are pinned because only the second is a safety property —
|
|
29
|
+
* the first is just the prefix doing its job. */
|
|
30
|
+
test("a version that isn't dotted-numeric can only ever withhold the nudge", () => {
|
|
31
|
+
expect(isBehind("1.2.0-rc.1", "1.183.0")).toBe(true); // the prefix already decides it: 2 is behind 183
|
|
32
|
+
expect(isBehind("1.2.0-rc.1", "1.2.0")).toBe(false); // the unparseable segment is where they differ — silence
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// The version every agent shipped before the release stamp existed. It is genuinely behind — it predates every
|
|
36
|
+
// release that has one — and it must read that way, because those are the installs this whole signal is for.
|
|
37
|
+
test("isBehind flags the hand-written version agents used to carry", () => {
|
|
38
|
+
expect(isBehind("0.1.0", "1.183.0")).toBe(true);
|
|
39
|
+
});
|
package/src/versions.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/* COMPARING THE VERSIONS THIS SYSTEM STAMPS ON WHAT IT SHIPS — the daemon, the sandbox image, and the two agents
|
|
2
|
+
* that run on a user's own computer. One release stamps all of them to the SAME version, so "is this one behind
|
|
3
|
+
* that one" is one question with one answer, and it lives here because both ends ask it: the daemon compares its
|
|
4
|
+
* own build against the latest published release, and the browser compares a computer's agent against the same.
|
|
5
|
+
*
|
|
6
|
+
* Shared rather than copied because the two copies would not disagree until the day it mattered — 1.9.0 against
|
|
7
|
+
* 1.10.0 is where a hand-rolled comparator goes wrong, and it goes wrong by reporting "up to date". */
|
|
8
|
+
|
|
9
|
+
// Release versions are plain dotted numerics (semantic-release picks them), so there is no semver dependency to
|
|
10
|
+
// take on. A MISSING segment counts as 0, which is what makes "1.2" and "1.2.0" the same version rather than
|
|
11
|
+
// adjacent ones.
|
|
12
|
+
export const isNewer = (a: string, b: string): boolean => {
|
|
13
|
+
const left = a.split(".").map(Number);
|
|
14
|
+
const right = b.split(".").map(Number);
|
|
15
|
+
for (let i = 0; i < Math.max(left.length, right.length); i++) {
|
|
16
|
+
const l = left[i] ?? 0;
|
|
17
|
+
const r = right[i] ?? 0;
|
|
18
|
+
if (l !== r) {
|
|
19
|
+
return l > r;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return false;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/* The sentinel every build that is NOT a release carries: the repo keeps 0.0.0 in package.json and only
|
|
26
|
+
* semantic-release stamps a real one, so a working-tree daemon and a locally-compiled agent both answer this.
|
|
27
|
+
*
|
|
28
|
+
* It has to be excluded from the comparison rather than merely lose it. Every published release outranks 0.0.0,
|
|
29
|
+
* so a developer running the agent they just built would be told, permanently, to replace it with something
|
|
30
|
+
* older than what they are running. The daemon already draws exactly this line for itself (isDevBuild). */
|
|
31
|
+
export const DEV_VERSION = `0.0.0`;
|
|
32
|
+
|
|
33
|
+
/* WHETHER TO TELL SOMEBODY THEIR BUILD IS OLD. Deliberately false in every uncertain case, and each one is a
|
|
34
|
+
* different kind of not-knowing:
|
|
35
|
+
*
|
|
36
|
+
* • no installed version — the thing does not report one, so there is nothing to be behind.
|
|
37
|
+
* • no latest version — this sandbox has not reached the registry (or is a dev build and never will).
|
|
38
|
+
* • installed is the dev sentinel — see above; a nag that cannot be satisfied is worse than silence.
|
|
39
|
+
*
|
|
40
|
+
* A version that is not dotted-numeric is compared by its numeric prefix, which is both useful and safe in the
|
|
41
|
+
* only direction that matters: a segment that will not parse compares as neither greater nor less, so it stops
|
|
42
|
+
* the comparison at "not newer". A malformed INSTALLED version can therefore only ever withhold a nag, never
|
|
43
|
+
* invent one — and `latest` comes from the registry, so it is well-formed by construction.
|
|
44
|
+
*
|
|
45
|
+
* The asymmetry is the point. Saying "you are out of date" wrongly sends someone to reinstall a working agent;
|
|
46
|
+
* saying nothing wrongly leaves them where they already were. */
|
|
47
|
+
export const isBehind = (installed: string | undefined, latest: string | undefined): boolean =>
|
|
48
|
+
installed !== undefined && latest !== undefined && installed !== DEV_VERSION && isNewer(latest, installed);
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { type Workflow, WorkflowSchema, type WorkflowStep } from "./schemas.js";
|
|
3
|
+
import { workflowFaults, workflowRunFaults } from "./workflow-faults.js";
|
|
4
|
+
|
|
5
|
+
/* The GATE rules. The graph rules beside them are exercised by the scheduler's own integration tests, which
|
|
6
|
+
* have a real run to check them against; these have none to check, which is the point — every fault here is one
|
|
7
|
+
* the workflow has to be refused for BEFORE a run, because the failure it prevents costs a full fan-out of
|
|
8
|
+
* sessions and then reports nothing anybody can act on.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const judge = (over: Partial<WorkflowStep> = {}): WorkflowStep => ({
|
|
12
|
+
id: "judge",
|
|
13
|
+
title: "Judge",
|
|
14
|
+
goal: "a release decision exists",
|
|
15
|
+
prompt: "weigh what the steps before you found",
|
|
16
|
+
needs: [],
|
|
17
|
+
handoff: "fresh",
|
|
18
|
+
output: { kind: "json", fields: [{ name: "release", type: "string", description: "pass | fail", required: true }] },
|
|
19
|
+
checks: [],
|
|
20
|
+
context: "fresh",
|
|
21
|
+
...over,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const gated = (over: Partial<Workflow> = {}): Workflow => ({
|
|
25
|
+
id: "release-gate",
|
|
26
|
+
name: "release gate",
|
|
27
|
+
steps: [judge()],
|
|
28
|
+
maxParallel: 1,
|
|
29
|
+
gate: { step: "judge", field: "release", pass: ["pass"] },
|
|
30
|
+
...over,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a well-formed gate is no fault at all", () => {
|
|
34
|
+
expect(workflowFaults(gated())).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("a workflow with no gate is judged on its graph alone", () => {
|
|
38
|
+
const { gate: _gate, ...ungated } = gated();
|
|
39
|
+
expect(workflowFaults(ungated)).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("duplicate output field names are one fault at both authoring and schema boundaries", () => {
|
|
43
|
+
const duplicate = gated({
|
|
44
|
+
steps: [
|
|
45
|
+
judge({
|
|
46
|
+
output: {
|
|
47
|
+
kind: "json",
|
|
48
|
+
fields: [
|
|
49
|
+
{ name: "release", type: "string", description: "pass | fail", required: true },
|
|
50
|
+
{ name: "release", type: "boolean", description: "whether to release", required: true },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(workflowFaults(duplicate)).toContain(`"Judge" declares the output field "release" more than once; field names must be unique.`);
|
|
58
|
+
expect(WorkflowSchema.safeParse(duplicate).success).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("a gate naming a step the workflow does not have is refused", () => {
|
|
62
|
+
const faults = workflowFaults(gated({ gate: { step: "nope", field: "release", pass: ["pass"] } }));
|
|
63
|
+
expect(faults).toEqual([`The gate reads step "nope", which is not a step in this workflow.`]);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("a gate on a step that declares no output fields is refused", () => {
|
|
67
|
+
const faults = workflowFaults(gated({ steps: [judge({ output: { kind: "claim" } })] }));
|
|
68
|
+
expect(faults).toEqual([`The gate reads "Judge", but that step declares no output fields for it to read.`]);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("a gate on a field the step does not declare is refused", () => {
|
|
72
|
+
const faults = workflowFaults(gated({ gate: { step: "judge", field: "shipit", pass: ["pass"] } }));
|
|
73
|
+
expect(faults).toEqual([`The gate reads "shipit", which "Judge" does not declare.`]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// A list has no reading as a release decision, and the one it would fall into (join and compare) is a rule
|
|
77
|
+
// nobody wrote down.
|
|
78
|
+
test("a gate on a list field is refused", () => {
|
|
79
|
+
const steps = [judge({ output: { kind: "json", fields: [{ name: "release", type: "string[]", description: "the verdicts", required: true }] } })];
|
|
80
|
+
const faults = workflowFaults(gated({ steps }));
|
|
81
|
+
expect(faults).toEqual([`The gate reads "release", which is a list — a release decision has to be one value.`]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// The expensive one to discover at run time: it passes every save, then blocks a release on the one commit
|
|
85
|
+
// where the model decided the field was not worth writing.
|
|
86
|
+
test("a gate on an optional field is refused", () => {
|
|
87
|
+
const steps = [judge({ output: { kind: "json", fields: [{ name: "release", type: "string", description: "pass | fail", required: false }] } })];
|
|
88
|
+
const faults = workflowFaults(gated({ steps }));
|
|
89
|
+
expect(faults).toEqual([`The gate reads "release", which "Judge" declares optional — it has to be required.`]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
/* THE RUN-TIME RULE, kept apart from every rule above it because it is not about the graph — and the graph is
|
|
93
|
+
* what gets saved. A design whose steps take their goal and instruction from the request is the ordinary shape
|
|
94
|
+
* and must save cleanly; it is only unrunnable on the particular run that brought no request.
|
|
95
|
+
*/
|
|
96
|
+
|
|
97
|
+
// The shape the whole feature is for: a saved design that says nothing about the task, pointed at today's job.
|
|
98
|
+
test("a design whose steps inherit is a perfectly good design", () => {
|
|
99
|
+
const inheriting = gated({ steps: [judge({ goal: undefined, prompt: undefined })] });
|
|
100
|
+
expect(workflowFaults(inheriting)).toEqual([]);
|
|
101
|
+
expect(workflowRunFaults(inheriting, "make the importer handle empty files")).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/* Refused at the door rather than discovered by the first step, because this is the one combination with
|
|
105
|
+
* nothing to tell the model at all — and by the time a step found out, the run has already opened a session per
|
|
106
|
+
* root and started paying for them.
|
|
107
|
+
*/
|
|
108
|
+
test("running an inheriting design with no request is refused", () => {
|
|
109
|
+
const inheriting = gated({ steps: [judge({ goal: undefined, prompt: undefined })] });
|
|
110
|
+
expect(workflowRunFaults(inheriting, undefined)).toHaveLength(1);
|
|
111
|
+
expect(workflowRunFaults(inheriting, undefined)[0]).toContain(`"Judge"`);
|
|
112
|
+
// Whitespace is not a request. The composer trims before sending, but the gate's webhook body does not.
|
|
113
|
+
expect(workflowRunFaults(inheriting, " \n ")).toHaveLength(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Only half-inheriting is still inheriting: a step with its own instruction but no goal is measured against
|
|
117
|
+
// the request, so it needs one just as much.
|
|
118
|
+
test("a step that declares only one of the two still needs a request", () => {
|
|
119
|
+
expect(workflowRunFaults(gated({ steps: [judge({ goal: undefined })] }), undefined)).toHaveLength(1);
|
|
120
|
+
expect(workflowRunFaults(gated({ steps: [judge({ prompt: undefined })] }), undefined)).toHaveLength(1);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// A design that says everything itself is startable from anywhere, with no composer behind it — which is what
|
|
124
|
+
// keeps the gate's webhook and the workflows page working for the designs written that way.
|
|
125
|
+
test("a design that declares everything runs with no request at all", () => {
|
|
126
|
+
expect(workflowRunFaults(gated(), undefined)).toEqual([]);
|
|
127
|
+
});
|