Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 54 additions & 30 deletions ops/NEXT.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,73 @@
# NEXT — Gate 3: Build the work package consumer
# NEXT — Consumer validates files_in_scope paths exist

**Scope:** Gate 3 — close the Garden's loop. CODE task, SDK-side. The picker EMITS a work package (sdk/src/backlog-picker.ts + testdata/backlog-picker.flow.yaml, merged, four tested properties) and NOTHING consumes it — that is the missing half. Build the consumer: an SDK entrypoint taking an emitted package and turning it into something runnable, validating it has a title, a non-empty scope and a definition of done, and REFUSING with a typed reason when it does not, because a package that cannot be verified must not become work. NOTE: two previous attempts (ee5c9b3e, 06c0d6ab) did this correctly and their files were LOST before delivery by a platform fault — the build sandbox's .git points at a directory that does not exist, so writes cannot be captured. You are not duplicating live work.
## Target scope (from gate 3)

## Objective
**Scope:** Harden the Garden's loop with a case it does not yet handle. CODE task, SDK-side.

On main now, all merged and tested:
- `sdk/src/backlog-picker.ts` — proposes a work package from ops/BACKLOG.md
- `sdk/src/work-package-consumer.ts` — judges one, refusing with a typed
reason (missing_title / missing_scope / missing_definition_of_done)
- `testdata/backlog-picker.flow.yaml` — the flow, with its canonical spec
- a test running the flow's real emit-package output through the consumer,
proving the two halves interoperate in both directions

So propose -> judge -> accept/refuse works end to end. What it does NOT do is
survive a hostile or malformed backlog. Pick ONE of these and do it properly:

(a) The picker reads whatever ops/BACKLOG.md contains. A malformed entry — a
bold title with no body, an unterminated backtick, a bullet nested under
another — should produce a typed refusal, never a crash and never a
half-formed package. Add the handling and the tests.

Build the SDK entrypoint that takes an emitted work package and turns it into something runnable. The consumer must validate that the package has:
- A title (non-empty string)
- A non-empty scope
- A definition of done
(b) The consumer accepts any package whose fields are present. It does not
check that files_in_scope names paths that EXIST, so a package can be
accepted while scoping files that are not there. Add that check as a new
typed refusal reason, with tests.

**Decision:** Implementing option (b) — files_in_scope path existence validation.

## Objective

When any of these is missing or invalid, the consumer REFUSES with a typed reason. A package that cannot be verified must not become work.
Add a new refusal reason to `work-package-consumer.ts` that rejects packages
scoping nonexistent files. A package accepted today while naming files that do
not exist (`sdk/src/does-not-exist.ts`) must be refused with a typed reason
(`nonexistent_files`).

## Files in scope

- `sdk/src/` (new consumer code)
- `sdk/tests/` (new consumer tests)
- NO changes to `kernel/` (PR #19 is open)
- NO changes to `sdk/src/demo-hn-monitor.ts` (PR #19 is open)
- `sdk/src/work-package-consumer.ts`
- `sdk/tests/work-package-consumer.test.ts`
- `sdk/src/index.ts` (if new types need export)

## Definition of done

1. **SDK code exists** that consumes an emitted work package
2. **Validation tests exist** for:
- Missing title → typed refusal
- Empty title → typed refusal
- Missing scope → typed refusal
- Empty scope → typed refusal
- Missing definition of done → typed refusal
- Empty definition of done → typed refusal
- Valid package → accepted
3. **Every new test is confirmed to FAIL against current code** with literal output pasted
4. **SDK test suite passes:**
1. **New typed refusal reason** `nonexistent_files` added to
`WorkPackageRefusalReason` type
2. **Path existence check** in `consumeWorkPackage` that refuses when
files_in_scope contains paths that do not exist on disk
3. **Tests covering the new behavior:**
- Test: refuse a package with all nonexistent files
- Test: refuse a package with a mix of existent and nonexistent files
- Test: accept a package with all existent files
4. **EVERY new test confirmed to FAIL against current code** — the literal
failing output must be quoted below when reporting BUILD_DONE
5. **Existing tests still passing** — all 8 existing work-package-consumer tests
green
6. **Full SDK suite passing:**
```
cd sdk && npm test
```
Paste the literal output showing all tests pass, 0 failed
5. **Final verification** — as the LAST action, run:
Must show green suite with exact pass count.
7. **As the LAST action, run and paste:**
```
git status --porcelain
```
And paste the output to make lost writes visible immediately

## Out of scope

- Kernel changes (different PR)
- Integration with hn-monitor (different PR)
- Any changes to the backlog picker itself (already merged in PRs #20, #21, #22)
- Changes to flow execution or scheduling
- Picker malformed-entry handling (option a) — that is NOT this run's target
- Checking that files are readable, only that they exist
- Recursively resolving directory globs — check the literal path only
- Any changes to kernel/, testdata/, or ops/
- Any changes to sdk/src/demo-hn-monitor.ts
15 changes: 14 additions & 1 deletion sdk/src/work-package-consumer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { existsSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

/** The work-package shape emitted at the SDK boundary. */
export interface EmittedWorkPackage {
title: string;
Expand All @@ -10,7 +13,8 @@ export interface EmittedWorkPackage {
export type WorkPackageRefusalReason =
| 'missing_title'
| 'missing_scope'
| 'missing_definition_of_done';
| 'missing_definition_of_done'
| 'nonexistent_files';

export type WorkPackageConsumption =
| { accepted: true; work: EmittedWorkPackage }
Expand All @@ -30,9 +34,18 @@ export function consumeWorkPackage(input: unknown): WorkPackageConsumption {
if (!isNonEmptyStringArray(input['definition_of_done'])) {
return { accepted: false, reason: 'missing_definition_of_done' };
}
if (!input['files_in_scope'].every(pathExists)) {
return { accepted: false, reason: 'nonexistent_files' };
}
return { accepted: true, work: input as unknown as EmittedWorkPackage };
}

const repositoryRoot = new URL('../../', import.meta.url);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve scope paths against the work package workspace

When this SDK consumes a package for another checkout or temporary worktree, this root is derived from the SDK module's installation location rather than the workspace containing the scoped files. Consequently, a real file such as src/only-here.ts in the target checkout is refused, while a same-named file in this repository can be accepted; an installed scoped package makes ../../ resolve inside node_modules/@relayflows instead. The Garden join test masks this by running the picker in a temporary directory but changing its emitted path to one that exists only in the flows checkout.

Useful? React with 👍 / 👎.


function pathExists(path: string): boolean {
return existsSync(fileURLToPath(new URL(path, repositoryRoot)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject paths that escape the repository root

For an absolute path or a path containing enough .. segments, new URL(path, repositoryRoot) discards or escapes the intended base, so packages such as files_in_scope: ['/etc/passwd'] or ['sdk/../../../etc/passwd'] are accepted whenever that external path exists. This bypasses the repository-relative scope validation—the picker itself filters leading / paths—and admits a package as runnable despite its declared scope pointing outside the repository.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Convert malformed path inputs into typed refusals

When an otherwise well-shaped package contains URL-sensitive input such as sdk/src/%2Fetc, fileURLToPath throws TypeError instead of returning the declared nonexistent_files refusal. Because consumeWorkPackage accepts unknown and promises refusal data for unverifiable packages, malformed nonempty path strings can currently crash the consumer rather than being handled through its typed result.

Useful? React with 👍 / 👎.

}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
Expand Down
25 changes: 24 additions & 1 deletion sdk/tests/work-package-consumer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ describe('work package consumer', () => {
it('accepts a valid package as runnable work', async () => {
expect(await consume(validPackage)).toEqual({ accepted: true, work: validPackage });
});

it('refuses a package when all files in scope are nonexistent', async () => {
expect(
await consume({ ...validPackage, files_in_scope: ['sdk/src/does-not-exist.ts'] }),
).toEqual({ accepted: false, reason: 'nonexistent_files' });
});

it('refuses a package when some files in scope are nonexistent', async () => {
expect(
await consume({
...validPackage,
files_in_scope: ['sdk/src/work-package-consumer.ts', 'sdk/src/does-not-exist.ts'],
}),
).toEqual({ accepted: false, reason: 'nonexistent_files' });
});

it('accepts a package when all files in scope exist', async () => {
const input = {
...validPackage,
files_in_scope: ['sdk/src/work-package-consumer.ts', 'sdk/tests/'],
};
expect(await consume(input)).toEqual({ accepted: true, work: input });
});
});

describe('the Garden join: picker output feeds the consumer', () => {
Expand Down Expand Up @@ -83,7 +106,7 @@ describe('the Garden join: picker output feeds the consumer', () => {
// An entry carrying a runnable command: that IS its definition of done.
writeFileSync(
join(dir, 'ops', 'BACKLOG.md'),
'# Backlog\n\n- **Actionable entry** touches `sdk/src/x.ts`, verified by `npm test --silent`\n',
'# Backlog\n\n- **Actionable entry** touches `sdk/src/work-package-consumer.ts`, verified by `npm test --silent`\n',
);
run(step('read-backlog'), dir);
run(step('select-entry'), dir);
Expand Down